diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c3fb0a9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.env +.env.local +.venv +.analysis-venv +.verify-venv +.ticket-verify-venv +__pycache__ +.pytest_cache +.mypy_cache +.ruff_cache +.git +.gitignore +tests diff --git a/.env.example b/.env.example index 1e71f19..1d455d5 100644 --- a/.env.example +++ b/.env.example @@ -1,25 +1,188 @@ -# Telegram Bot -BOT_TOKEN=YOUR_BOT_TOKEN_HERE +# ========================= +# Telegram bot +# ========================= -# Database -DB_HOST=localhost -DB_USER=root -DB_PASSWORD= -DB_NAME=botyobshik +# Токен бота от BotFather +BOT_TOKEN=1234567890:REPLACE_WITH_BOT_TOKEN -# Support -SUPPORT_USERNAME=@support_username +# Telegram ID администраторов через запятую +# Пример: 123456789,987654321 +BOT_ADMIN_IDS=123456789 -# Payment -PAYMENT_CHANNEL_ID=-1003591520479 -PAYMENT_THREAD_ID=5910 -PAYMENT_TICKET_CHANNEL_ID=-1003591520479 -PAYMENT_TICKET_THREAD_ID=788 -PAYMENT_PHONE=+7 904 788 77 35 +# Telegram ID модераторов через запятую +# Модераторы могут отвечать на тикеты и подтверждать оплаты +BOT_MODERATOR_IDS= -# Admin -ADMIN_IDS=123456789,987654321 +# Прокси для доступа к Telegram Bot API, если хост не видит api.telegram.org напрямую +# Подходят обычные URL: http://... или socks5://... +TELEGRAM_PROXY_URL= -# Remnawave API -REMWAVE_API_URL=https://panel.remnawave.com/api -REMWAVE_API_KEY=your_api_key_here +# Название бренда в пользовательской панели +BOT_BRAND_NAME=OREOL VPN + +# Username бота без @, нужен для реферальных ссылок +BOT_PUBLIC_USERNAME=your_bot_username + +# Опциональная ссылка на поддержку +BOT_SUPPORT_URL=https://t.me/your_support_account + +# Опциональная внешняя ссылка на Terms of Use +BOT_TERMS_URL= + +# Ссылка на приватный чат/топик для тикетов поддержки +# Формат: https://t.me/c/// +BOT_SUPPORT_TICKET_LINK=https://t.me/c/1234567890/2/3 + +# Альтернативный способ указать chat id для тикетов вручную +# Если заполнен, имеет приоритет над BOT_SUPPORT_TICKET_LINK +BOT_SUPPORT_TICKET_CHAT_ID= + +# Альтернативный способ указать thread/topic id для тикетов вручную +# Если > 0, имеет приоритет над BOT_SUPPORT_TICKET_LINK +BOT_SUPPORT_TICKET_THREAD_ID=0 + +# Включить стартовую картинку в панели +BOT_START_IMAGE_ENABLED=true + +# Путь до стартовой картинки +# Можно абсолютный путь или относительный от корня проекта +BOT_START_IMAGE_PATH=assets/main.png + + +# ========================= +# Remnawave +# ========================= + +# Базовый URL Remnawave без /api или с /api — клиент нормализует сам +REMNAWAVE_BASE_URL=https://panel.example.com + +# API token Remnawave +REMNAWAVE_API_TOKEN=replace-with-remnawave-api-token + +# Дополнительный X-Api-Key, если API закрыт через Caddy +REMNAWAVE_CADDY_API_KEY= + +# HTTP timeout в секундах для запросов к Remnawave +REMNAWAVE_TIMEOUT_SECONDS=20 + + +# ========================= +# MariaDB +# ========================= + +# Хост MariaDB +DB_HOST=127.0.0.1 + +# Порт MariaDB +DB_PORT=3306 + +# Имя базы данных +DB_NAME=telegabot + +# Пользователь MariaDB +DB_USER=telegabot + +# Пароль MariaDB +DB_PASSWORD=change_me + +# Root-пароль MariaDB для docker-compose +# Нужен контейнеру db при первом запуске +DB_ROOT_PASSWORD=change_root_me + +# Логировать SQLAlchemy SQL-запросы в консоль +# true = логировать, false = не логировать +DB_ECHO=false + +# Попытаться создать базу при старте, если её ещё нет +CREATE_DATABASE_ON_START=true + +# Создавать таблицы ORM при старте приложения +CREATE_TABLES_ON_START=true + +# Сохранять историю subscription request log из Remnawave +SYNC_SUBSCRIPTION_HISTORY=true + +# Размер батча при /sync_all +SYNC_BATCH_SIZE=100 + +# Уровень логов Python +# Примеры: INFO, DEBUG, WARNING, ERROR +LOG_LEVEL=INFO + + +# ========================= +# Manual payment flow +# ========================= + +# Набор тарифов в формате дни:цена_в_рублях +# Пример: 30:250,180:600,365:1000 +PAYMENT_PLANS=30:250,180:600,365:1000 + +# Реквизиты или инструкция для перевода +# Этот текст бот показывает пользователю после выбора тарифа +PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN + +# Ссылка на приватный чат/топик, куда бот отправляет чеки на модерацию +# Формат: https://t.me/c/// +PAYMENT_REVIEW_LINK=https://t.me/c/1234567890/56/57 + +# Альтернативный способ указать chat id review-чата вручную +# Если заполнен, имеет приоритет над PAYMENT_REVIEW_LINK +PAYMENT_REVIEW_CHAT_ID= + +# Альтернативный способ указать thread/topic id review-чата вручную +# Если > 0, имеет приоритет над PAYMENT_REVIEW_LINK +PAYMENT_REVIEW_THREAD_ID=0 + +# Процент скидки по реферальному коду +REFERRAL_DISCOUNT_PERCENT=5 + +# Бонус рефереру за подтверждённую оплату приглашённого пользователя +# Начисляется в днях и продлевает доступ +REFERRAL_BONUS_DAYS=7 + + +# ========================= +# Payment / Remnawave provisioning +# ========================= + +# Резервный код тарифа по умолчанию +# Сейчас в основном используется PAYMENT_PLANS, но поле оставлено для совместимости +PAYMENT_PLAN_CODE=vpn_30d + +# Резервное название базового тарифа +PAYMENT_PLAN_TITLE=OREOL VPN на 30 дней + +# Резервное описание базового тарифа +PAYMENT_PLAN_DESCRIPTION=Доступ к VPN на 30 дней + +# Резервная цена базового тарифа +PAYMENT_PLAN_PRICE_STARS=250 + +# Резервная длительность базового тарифа +PAYMENT_PLAN_DURATION_DAYS=30 + +# Лимит трафика в GB +# 0 = без лимита +PAYMENT_PLAN_TRAFFIC_LIMIT_GB=0 + +# Стратегия сброса трафика для Remnawave +# Обычно NO_RESET +PAYMENT_PLAN_TRAFFIC_RESET_PERIOD=NO_RESET + +# UUID внутренних групп Remnawave, которые назначаются новому или продлеваемому доступу +# Несколько значений через запятую +PAYMENT_INTERNAL_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 + +# UUID внешней группы Remnawave, если используется +PAYMENT_EXTERNAL_SQUAD_UUID= + +# Текст для пользователя, если оплата отклонена или доступ не выдался +PAYMENT_SUPPORT_TEXT=Если оплата прошла, но доступ не выдался, напишите в поддержку. + +# Префикс логина Remnawave +# Итоговый логин будет вида Oreol-- +PAYMENT_USERNAME_PREFIX=Oreol + +# Tag для пользователей, создаваемых ботом в Remnawave +PAYMENT_USER_TAG=BOT diff --git a/.gitignore b/.gitignore index 5e71dd1..d00c96c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,42 +1,16 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -env/ -venv/ -ENV/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# Environment .env .env.local - -# Logs -*.log - -# OS -.DS_Store -Thumbs.db +.venv/ +.analysis-venv/ +.verify-venv/ +.ticket-verify-venv/ +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.py[cod] +*.pyo +*.pyd +*.egg-info/ +build/ +dist/ diff --git a/.qwen/settings.json b/.qwen/settings.json deleted file mode 100644 index a3522da..0000000 --- a/.qwen/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebFetch(github.com)" - ] - } -} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3613435 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY . . + +RUN pip install --no-cache-dir . + +CMD ["python", "main.py"] diff --git a/README.md b/README.md index 64f36ae..6e285f6 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,785 @@ -# OreolRP Subscription Bot +# OREOL VPN Bot -Telegram-бот для продажи подписок на **aiogram 3.x** с поддержкой нескольких языков. +Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`, локальным кэшем в `MariaDB`, пользовательской панелью, тикетами поддержки, реферальной системой и ручной оплатой с подтверждением админом. + +Этот README написан как рабочий handoff-документ: по нему можно поднять проект, понять текущую архитектуру и продолжить разработку в новой сессии без восстановления контекста по кускам. + +## Что умеет бот + +- Показывает стартовую панель пользователя через `/start` +- Подтягивает аккаунты Remnawave по `telegramId` +- Хранит локальный кэш пользователей и истории подписок в MariaDB +- Поддерживает привязку существующего аккаунта Remnawave по `short_uuid` +- Показывает профиль, статус подписки, срок и трафик +- Работает с реферальными кодами и скидкой перед оплатой +- Поддерживает ручную оплату переводом с отправкой чека в бот +- Отправляет чек в отдельный Telegram-канал/топик на проверку +- Даёт админу или модератору подтвердить или отклонить оплату +- После подтверждения создаёт или продлевает доступ в Remnawave +- Поддерживает тикеты в отдельный канал и ответы только от админа/модератора +- Показывает админам отдельную Telegram-панель с метриками и быстрыми действиями + +## Текущий сценарий оплаты + +Сейчас в проекте не используется Telegram Stars. Оплата работает вручную: + +1. Пользователь нажимает `Купить подписку` +2. Бот показывает список тарифов из `PAYMENT_PLANS` +3. Если у пользователя применён реферальный код, скидка учитывается до выбора тарифа +4. После выбора тарифа бот показывает реквизиты из `PAYMENT_TRANSFER_TEXT` +5. Пользователь отправляет чек следующим сообщением в бота +6. Бот пересылает чек в review-чат оплаты +7. Админ или модератор нажимает `Подтвердить` или `Отклонить` +8. При подтверждении бот создаёт или продлевает доступ в Remnawave и отправляет пользователю подписку + +### Тарифы + +Тарифы задаются одной переменной: + +```env +PAYMENT_PLANS=30:250,180:600,365:1000 +``` + +Формат: + +- `дни:цена_в_рублях` +- элементы разделяются запятой + +Пример выше означает: + +- 30 дней = 250 ₽ +- 180 дней = 600 ₽ +- 365 дней = 1000 ₽ + +### Логин в Remnawave + +Логин создаётся в формате: + +```text +Oreol-- +``` + +Пример: + +```text +Oreol-591220249-slkes +``` + +Если username отсутствует, используется `first_name`, а если и его нет, то `user`. + +Важно: + +- логин обрезается до 36 символов, чтобы соответствовать ограничениям Remnawave +- повторная покупка у того же пользователя не должна создавать новый логин, а должна продлевать уже существующий доступ + +## Важное замечание по базе + +Если таблица `payment_orders` уже была создана старой версией проекта, в ней мог остаться `UNIQUE` на `provision_username`. + +Для текущей логики это неверно, потому что один и тот же логин может использоваться в нескольких заказах одного пользователя при продлении. + +Правильное состояние: + +- в ORM `provision_username` обычный индекс без `UNIQUE` +- в `sql/schema.sql` тоже без `UNIQUE` + +Если таблица уже существует в MariaDB, может потребоваться вручную убрать старое уникальное ограничение. + +## Архитектура + +### Основные слои + +- `app/main.py` + - инициализация приложения + - подключение к БД + - создание клиента Remnawave + - создание `SyncService`, `SupportTicketService`, `PaymentService` + - запуск aiogram + +- `app/config.py` + - все настройки из `.env` + - парсинг ID админов и модераторов + - парсинг ссылок Telegram вида `https://t.me/c/.../.../...` + - парсинг тарифов оплаты + +- `app/services/remnawave_client.py` + - прямой HTTP-клиент к Remnawave API через `httpx` + - `GET /users/by-telegram-id/{telegramId}` + - `GET /users/{uuid}` + - `GET /users/by-short-uuid/{shortUuid}` + - `GET /users/by-username/{username}` + - `POST /users` + - `PATCH /users` + - `POST /users/resolve` + - `GET /users` + - `GET /users/{uuid}/subscription-request-history` + +- `app/services/sync_service.py` + - регистрация Telegram-пользователей + - синхронизация пользователей из Remnawave в MariaDB + - привязка аккаунтов по `short_uuid` + - реферальные коды и приглашения + +- `app/services/payment_service.py` + - список доступных тарифов + - создание ручного заказа + - расчёт скидки по реферальному коду + - хранение статусов заказа + - создание нового доступа в Remnawave + - продление существующего доступа в Remnawave + +- `app/services/support_ticket_service.py` + - создание тикетов + - поиск тикета по support message ID + - отметка, что тикет обработан + +- `app/bot/handlers/main.py` + - все команды и callback-и + - стартовая панель + - меню оплаты + - FSM для тикетов, рефкодов и чека оплаты + - приём ответа модератора на тикет + - приём кнопок `Подтвердить / Отклонить` в review-канале оплаты + +- `app/bot/ui/panel.py` + - рендер панели пользователя + - инлайн-кнопки разделов + - тексты профиля, подписки, рефералки, правил и поддержки + +- `app/utils/formatters.py` + - форматирование дат + - форматирование трафика + - форматирование карточек доступа ## Структура проекта -``` -botyobshik/ -├── main.py # Точка входа -├── requirements.txt # Зависимости -├── .env # Конфигурация (не в git) -├── .env.example # Пример конфигурации -├── README.md # Документация -├── config/ -│ └── __init__.py # Загрузка настроек из .env -├── core/ -│ └── __init__.py # Работа с БД (авто-создание) -├── handlers/ -│ └── __init__.py # Обработчики команд -├── keyboards/ -│ └── __init__.py # Клавиатуры -└── locales/ - ├── __init__.py # Локализация - ├── ru.json # Русский - ├── en.json # English - └── kz.json # Қазақша +```text +telegabot/ +├── app/ +│ ├── bot/ +│ │ ├── handlers/ +│ │ │ └── main.py +│ │ └── ui/ +│ │ └── panel.py +│ ├── db/ +│ │ ├── base.py +│ │ ├── models.py +│ │ └── session.py +│ ├── schemas/ +│ │ └── remnawave.py +│ ├── services/ +│ │ ├── payment_service.py +│ │ ├── remnawave_client.py +│ │ ├── support_ticket_service.py +│ │ └── sync_service.py +│ ├── utils/ +│ │ └── formatters.py +│ ├── config.py +│ └── main.py +├── assets/ +│ └── main.png +├── sql/ +│ └── schema.sql +├── tests/ +│ ├── test_config.py +│ ├── test_formatters.py +│ ├── test_panel_ui.py +│ └── test_remnawave_client.py +├── .env.example +├── docker-compose.yml +├── Dockerfile +├── main.py +├── pyproject.toml +├── README.md +└── run.bat ``` -## Быстрый старт +## Команды бота + +### Пользовательские + +- `/start` — открыть стартовую панель + +### Админские + +- `Админ-панель` в UI — сводка по боту, поиск пользователя, полная синхронизация, быстрые переходы в рабочие чаты +- `/lookup ` — найти и синхронизировать пользователя +- `/sync_all` — массовая синхронизация пользователей Remnawave в MariaDB + +## База данных + +### Основные таблицы + +- `telegram_users` + - Telegram-пользователи, взаимодействовавшие с ботом + +- `remnawave_users` + - локальный кэш пользователей Remnawave + +- `internal_squads` + - справочник внутренних групп + +- `remnawave_user_internal_squads` + - связь many-to-many пользователей и групп + +- `subscription_request_logs` + - история запросов подписки + +- `support_tickets` + - тикеты поддержки + +- `referral_codes` + - персональные коды пользователей + +- `referral_invites` + - кто кого пригласил + +- `referral_bonuses` + - начисленные и ожидающие применения бонусы реферерам + +- `payment_orders` + - ручные платежи, статусы и выданные доступы + +### Статусы заказов оплаты + +Сейчас используются: + +- `PENDING` — заказ создан, чек ещё не отправлен +- `REVIEW` — чек отправлен на проверку +- `REJECTED` — платёж отклонён модератором +- `FULFILLED` — доступ выдан или продлён + +## Реферальная система + +Что реализовано: + +- каждому пользователю создаётся персональный код +- можно сгенерировать ссылку вида `https://t.me/?start=ref_` +- сам переход по ссылке скидку не активирует +- чтобы получить скидку, пользователь должен вручную ввести код до оплаты +- размер скидки задаётся в `REFERRAL_DISCOUNT_PERCENT` +- за каждую подтверждённую оплату по рефкоду реферер получает бонус в днях +- размер бонуса задаётся в `REFERRAL_BONUS_DAYS` + +## Тикеты поддержки + +Сценарий: + +1. Пользователь открывает раздел `Поддержка` +2. Нажимает `Создать тикет` +3. Отправляет следующим сообщением вопрос или описание проблемы +4. Бот отправляет тикет в support-чат +5. Админ или модератор отвечает реплаем на сообщение с тикетом +6. Бот доставляет ответ пользователю в личный чат + +Доступ к ответу на тикет есть только у: + +- пользователей из `BOT_ADMIN_IDS` +- пользователей из `BOT_MODERATOR_IDS` + +## Переменные окружения + +Полный шаблон лежит в `.env.example`. + +Критический минимум для запуска: + +- `BOT_TOKEN` +- `REMNAWAVE_BASE_URL` +- `REMNAWAVE_API_TOKEN` +- `DB_HOST` +- `DB_PORT` +- `DB_NAME` +- `DB_USER` +- `DB_PASSWORD` +- `PAYMENT_INTERNAL_SQUAD_UUIDS` + +Для полного сценария поддержки и оплаты также нужны: + +- `BOT_PUBLIC_USERNAME` +- `BOT_SUPPORT_TICKET_LINK` или `BOT_SUPPORT_TICKET_CHAT_ID` +- `PAYMENT_TRANSFER_TEXT` +- `PAYMENT_REVIEW_LINK` или `PAYMENT_REVIEW_CHAT_ID` + +## Как настроить `.env` + +1. Скопировать шаблон: + +```bat +copy .env.example .env +``` + +2. Заполнить обязательные значения + +3. Проверить: + +- корректен ли `BOT_TOKEN` +- доступен ли `REMNAWAVE_BASE_URL` +- валиден ли `REMNAWAVE_API_TOKEN` +- существует ли MariaDB и есть ли права на создание БД, если `CREATE_DATABASE_ON_START=true` +- указаны ли реальные UUID групп в `PAYMENT_INTERNAL_SQUAD_UUIDS` +- указаны ли реальные реквизиты в `PAYMENT_TRANSFER_TEXT` + +## Пример важных настроек + +```env +BOT_BRAND_NAME=OREOL VPN +BOT_PUBLIC_USERNAME=oreol_vpn_bot + +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_NAME=oreolvpn +DB_USER=oreolvpn +DB_PASSWORD=strong_password + +PAYMENT_PLANS=30:250,180:600,365:1000 +PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN +PAYMENT_REVIEW_LINK=https://t.me/c/3646494169/56/57 +PAYMENT_INTERNAL_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 +REFERRAL_DISCOUNT_PERCENT=5 +PAYMENT_USERNAME_PREFIX=Oreol +``` + +## Поддержка Telegram-ссылок `t.me/c/...` + +Проект умеет разбирать приватные ссылки Telegram вида: + +```text +https://t.me/c/3646494169/56/57 +``` + +Разбор происходит так: + +- chat id = `-1003646494169` +- thread id = `56` +- последний сегмент `57` — это message id, для маршрутизации топика он не нужен + +Это используется для: + +- `BOT_SUPPORT_TICKET_LINK` +- `PAYMENT_REVIEW_LINK` + +## Как запускать + +### Вариант 1. Windows через `run.bat` + +```bat +run.bat +``` + +Что делает батник: + +- переходит в папку проекта +- проверяет `.env` +- создаёт `.venv`, если его нет +- ставит зависимости +- запускает `main.py` + +### Вариант 2. Ручной локальный запуск + +```bat +py -3 -m venv .venv +.venv\Scripts\python -m pip install -e .[dev] +.venv\Scripts\python main.py +``` + +### Вариант 3. Docker -1. **Установите зависимости:** ```bash -pip install -r requirements.txt +docker compose up --build ``` -2. **Создайте `.env` файл:** +Это полный запуск вместе с MariaDB. База данных уже есть в проекте: сервис `db` описан в `docker-compose.yml`, данные сохраняются в volume `mariadb_data`. + +#### Что нужно установить заранее + +- Docker Desktop на Windows/macOS +- или Docker Engine + Docker Compose plugin на Linux + +Проверьте, что команды доступны: + +```bash +docker --version +docker compose version +``` + +#### Ubuntu Server 24.04: установка Docker Engine + +Для `Ubuntu Server 24.04` рекомендуется ставить Docker Engine из официального Docker-репозитория. + +1. Обновить пакеты и поставить базовые утилиты: + +```bash +sudo apt update +sudo apt install -y ca-certificates curl git +``` + +2. Добавить официальный GPG-ключ Docker: + +```bash +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 +``` + +3. Подключить Docker repository: + +```bash +sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null < telegabot +cd telegabot +cp .env.example .env +nano .env +docker compose up -d --build ``` -Используйте `\n` для переноса строки. +#### Как проверить, что всё поднялось -## База данных +Посмотреть список контейнеров: -При запуске бот автоматически создаёт: -- Базу данных `botyobshik` -- Таблицу `subscriptions` (подписки) -- Таблицу `user_languages` (языки пользователей) +```bash +docker compose ps +``` + +Посмотреть логи бота: + +```bash +docker compose logs -f bot +``` + +Посмотреть логи базы: + +```bash +docker compose logs -f db +``` + +Проверить, что бот видит базу и база отвечает: + +```bash +docker compose exec db mariadb -u"$DB_USER" -p"$DB_PASSWORD" -e "SHOW DATABASES;" +``` + +Если нужно зайти в MariaDB вручную: + +```bash +docker compose exec db mariadb -u"$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" +``` + +Если всё настроено правильно, сценарий такой: + +1. поднимается `db` +2. healthcheck MariaDB становится `healthy` +3. стартует `bot` +4. бот при старте создаёт БД и таблицы, если включены: + - `CREATE_DATABASE_ON_START=true` + - `CREATE_TABLES_ON_START=true` + +#### Остановка и повторный запуск + +Остановить контейнеры: + +```bash +docker compose down +``` + +Запустить снова без пересборки: + +```bash +docker compose up -d +``` + +Пересобрать после изменения кода: + +```bash +docker compose up -d --build +``` + +#### Как удалить всё вместе с базой + +Если нужен полный сброс, включая MariaDB volume: + +```bash +docker compose down -v +``` + +После этого база будет создана заново при следующем запуске. + +#### Обновление на Ubuntu Server + +Если вы обновили код проекта: + +```bash +git pull +docker compose up -d --build +``` + +Если меняли только `.env`, обычно достаточно: + +```bash +docker compose up -d +``` + +#### Если бот не стартует + +Проверьте по пунктам: + +- заполнен ли `BOT_TOKEN` +- доступен ли `REMNAWAVE_BASE_URL` +- корректен ли `REMNAWAVE_API_TOKEN` +- заполнен ли `PAYMENT_INTERNAL_SQUAD_UUIDS` +- не пустые ли `DB_PASSWORD` и `DB_ROOT_PASSWORD` +- есть ли у сервиса `db` статус `healthy` в `docker compose ps` + +Если хотите открыть MariaDB наружу для внешнего клиента, это нужно делать осознанно: добавьте `ports` обратно в сервис `db` и отдельно ограничьте доступ через firewall или private network. + +#### Минимальный сценарий запуска в Docker + +1. Скопировать `.env.example` в `.env` +2. Заполнить Telegram, Remnawave и DB-переменные +3. Выполнить `docker compose up -d --build` +4. Проверить `docker compose logs -f bot` + +## Проверка проекта + +### Синтаксис + +```bat +py -3 -m compileall app main.py tests +``` + +### Тесты + +```bat +.venv\Scripts\python -m pytest +``` + +Если основная `.venv` невалидна из-за переезда проекта между машинами, пересоздай её. + +## Что уже протестировано + +Покрыто тестами: + +- парсинг конфигурации +- разбор support/review ссылок Telegram +- парсинг тарифов +- форматтеры текста +- UI панели +- базовые методы `RemnawaveApiClient` + +Последняя локальная проверка в этой сетевой копии: + +- `py -3 -m compileall app main.py tests` — успешно +- `pytest` — `26 passed` + +## Известные нюансы + +### 1. Перенос `.venv` между машинами + +Если скопировать проект вместе с уже готовой `.venv` в другую папку, на другой компьютер или на сетевой диск, окружение может ссылаться на старый путь Python. + +Типичная ошибка: + +```text +did not find executable at ...python.exe +``` + +Решение: + +1. удалить `.venv` +2. создать её заново +3. снова установить зависимости + +### 2. Ошибка `Unknown database` + +Если MariaDB доступна, но самой базы из `DB_NAME` ещё нет, можно увидеть: + +```text +OperationalError: (1049, "Unknown database '...'" ) +``` + +Что делать: + +- оставить `CREATE_DATABASE_ON_START=true`, если у пользователя есть права на создание БД +- либо создать БД вручную + +### 3. Прокси Telegram + +Для `TELEGRAM_PROXY_URL` подходят обычные proxy URL: + +- `http://user:pass@host:port` +- `socks5://host:port` + +Не подходят клиентские ссылки вроде: + +- `vless://...` +- `vmess://...` +- `trojan://...` +- `tg://proxy?...` для aiogram напрямую + +### 4. Review-чат оплаты должен быть настроен + +Если `PAYMENT_REVIEW_LINK` и `PAYMENT_REVIEW_CHAT_ID` пустые, пользователь сможет выбрать тариф, но бот не сможет отправить чек на модерацию. + +### 5. Internal squads обязательны для выдачи доступа + +Если `PAYMENT_INTERNAL_SQUAD_UUIDS` пустой, бот не сможет корректно создать доступ в Remnawave. + +## Безопасность + +Нельзя публиковать: + +- реальный `.env` +- `BOT_TOKEN` +- `REMNAWAVE_API_TOKEN` +- `REMNAWAVE_CADDY_API_KEY` +- банковские реквизиты из рабочего `PAYMENT_TRANSFER_TEXT` + +Передавать между сессиями безопасно: + +- код проекта +- `README.md` +- `.env.example` + +## Что логично делать дальше + +Следующие разумные шаги по проекту: + +- добавить миграции через Alembic +- хранить отдельный audit log по модерации оплат +- сделать уведомление модераторам о новых чеках +- добавить историю заказов пользователя в панели +- сделать админ-панель со списком платежей +- добавить продление конкретного существующего аккаунта по выбору, если у пользователя их несколько +- добавить отдельный статус `APPROVED_BUT_NOT_DELIVERED`, если бот не смог отправить результат пользователю +- покрыть тестами `PaymentService` и FSM оплаты + +## Быстрый handoff + +Если проект открывает новая сессия, порядок такой: + +1. Прочитать этот README +2. Проверить `.env` +3. Проверить, рабочая ли `.venv` +4. Убедиться, что доступны MariaDB и Remnawave +5. Запустить `run.bat` или `python main.py` +6. Проверить руками: + - `/start` + - открытие раздела подписки через кнопку в панели + - ввод реферального кода + - отправку чека + - подтверждение оплаты модератором + - выдачу ссылки пользователю + - поддержку через тикет diff --git a/__init__.py b/__init__.py deleted file mode 100644 index cb585e0..0000000 --- a/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# OreolRP Subscription Bot diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..18b665e --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Application package.""" diff --git a/app/bot/__init__.py b/app/bot/__init__.py new file mode 100644 index 0000000..7c3397f --- /dev/null +++ b/app/bot/__init__.py @@ -0,0 +1 @@ +"""Bot package.""" diff --git a/app/bot/handlers/__init__.py b/app/bot/handlers/__init__.py new file mode 100644 index 0000000..b87316a --- /dev/null +++ b/app/bot/handlers/__init__.py @@ -0,0 +1 @@ +"""Bot handlers package.""" diff --git a/app/bot/handlers/main.py b/app/bot/handlers/main.py new file mode 100644 index 0000000..82a1b42 --- /dev/null +++ b/app/bot/handlers/main.py @@ -0,0 +1,2499 @@ +from __future__ import annotations + +import html +import secrets +from pathlib import Path +from urllib.parse import quote_plus + +from aiogram import Bot, F, Router +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.filters import Command, CommandObject, CommandStart +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, Message +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from app.bot.ui.panel import ( + PANEL_SECTION_ADMIN, + PANEL_SECTION_HOME, + PANEL_SECTION_REFERRAL, + PANEL_SECTION_SUBSCRIPTION, + PANEL_SECTION_SUPPORT, + PANEL_SECTION_SUPPORT_TICKET, + PANEL_SECTION_TERMS, + PanelContext, + build_panel_keyboard, + render_panel_caption, +) +from app.config import Settings +from app.services.bot_config_service import BotConfigService, BotConfigSnapshot, StaticBotConfigService +from app.services.payment_service import ( + FULFILLED_PAYMENT_STATUS, + PENDING_PAYMENT_STATUS, + REJECTED_PAYMENT_STATUS, + REVIEW_PAYMENT_STATUS, + CreatedPaymentOrder, + PaymentPlan, + PaymentService, + StoredPaymentOrder, +) +from app.services.remnawave_client import RemnawaveApiError +from app.services.support_ticket_service import SupportTicketService +from app.services.sync_service import ( + AdminTelegramUserProfile, + AdminTelegramUsersPage, + CachedUserView, + SyncService, +) +from app.utils.formatters import ( + build_help_text, + format_bytes, + format_datetime, + format_datetime_with_days_left, + format_issued_access, + format_traffic_limit, + format_user_card, +) + + +class SupportTicketStates(StatesGroup): + waiting_for_message = State() + + +class ReferralCodeStates(StatesGroup): + waiting_for_code = State() + + +class ManualPaymentStates(StatesGroup): + waiting_for_receipt = State() + + +class AdminLookupStates(StatesGroup): + waiting_for_identifier = State() + + +class AdminConfigStates(StatesGroup): + waiting_for_value = State() + + +def build_router( + *, + settings: Settings, + sync_service: SyncService, + support_ticket_service: SupportTicketService, + payment_service: PaymentService, + bot_config_service: StaticBotConfigService, +) -> Router: + router = Router(name="main") + project_root = Path(__file__).resolve().parents[3] + default_start_image_path = project_root / "assets" / "main.png" + + async def get_runtime_config() -> BotConfigSnapshot: + return await bot_config_service.get_snapshot() + + def resolve_start_image_path(raw_path: str) -> Path | None: + if not raw_path.strip(): + return default_start_image_path if default_start_image_path.is_file() else None + + image_path = Path(raw_path).expanduser() + if not image_path.is_absolute(): + image_path = project_root / image_path + + return image_path if image_path.is_file() else None + + def get_start_image_path(config: BotConfigSnapshot) -> Path | None: + if not config.bot_start_image_enabled: + return None + + return resolve_start_image_path(config.bot_start_image_path) + + def payment_callback(action: str, value: str | None = None) -> str: + return f"payment:{action}:{value}" if value else f"payment:{action}" + + async def send_branded( + bot_instance: Bot, + *, + chat_id: int, + text: str, + reply_markup=None, + ) -> None: + """Send a message with brand image if enabled and caption fits.""" + config = await get_runtime_config() + start_image_path = get_start_image_path(config) + if start_image_path is not None and len(text) <= 1024: + await bot_instance.send_photo( + chat_id=chat_id, + photo=FSInputFile(str(start_image_path)), + caption=text, + reply_markup=reply_markup, + ) + return + await bot_instance.send_message( + chat_id=chat_id, + text=text, + reply_markup=reply_markup, + ) + + async def answer_branded( + message: Message, + text: str, + reply_markup=None, + ) -> None: + """Answer message with brand image if enabled and caption fits.""" + config = await get_runtime_config() + start_image_path = get_start_image_path(config) + if start_image_path is not None and len(text) <= 1024: + await message.answer_photo( + FSInputFile(str(start_image_path)), + caption=text, + reply_markup=reply_markup, + ) + return + await message.answer(text, reply_markup=reply_markup) + + async def show_branded_view( + callback: CallbackQuery, + *, + text: str, + reply_markup, + ) -> None: + """Show a branded text view — new message with image if possible.""" + if callback.message is None: + await callback.answer() + return + + config = await get_runtime_config() + start_image_path = get_start_image_path(config) + if start_image_path is not None and len(text) <= 1024: + await callback.message.answer_photo( + FSInputFile(str(start_image_path)), + caption=text, + reply_markup=reply_markup, + ) + return + + try: + if callback.message.photo or callback.message.document: + await callback.message.answer(text, reply_markup=reply_markup) + else: + await callback.message.edit_text(text, reply_markup=reply_markup) + except TelegramBadRequest as exc: + if "message is not modified" not in str(exc).lower(): + raise + + async def ensure_sender(message: Message) -> bool: + if message.from_user is None: + return False + if await sync_service.is_user_blocked(message.from_user.id): + await message.answer( + "\u26d4 Ваш аккаунт заблокирован. Обратитесь в поддержку." + ) + return False + return True + + async def clear_interactive_states(state: FSMContext) -> None: + if await state.get_state() in { + SupportTicketStates.waiting_for_message.state, + ReferralCodeStates.waiting_for_code.state, + ManualPaymentStates.waiting_for_receipt.state, + AdminLookupStates.waiting_for_identifier.state, + AdminConfigStates.waiting_for_value.state, + }: + await state.clear() + + async def load_panel_context( + *, + telegram_id: int, + username: str | None, + first_name: str | None, + last_name: str | None, + language_code: str | None, + sync_remote: bool, + ) -> PanelContext: + users: list[CachedUserView] = [] + api_error: str | None = None + is_admin = settings.is_admin(telegram_id) + config = await get_runtime_config() + + if sync_remote: + try: + await payment_service.apply_pending_referral_bonuses( + telegram_id=telegram_id, + refresh_cache=False, + ) + except RemnawaveApiError: + pass + + try: + users = await sync_service.sync_users_for_telegram( + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + language_code=language_code, + ) + except RemnawaveApiError as exc: + api_error = exc.message + users = await sync_service.get_cached_users_for_telegram(telegram_id) + else: + users = await sync_service.get_cached_users_for_telegram(telegram_id) + + display_name = " ".join(part for part in [first_name, last_name] if part).strip() + if not display_name: + display_name = username or "Пользователь" + + referral_summary = await sync_service.get_referral_summary(telegram_id) + referral_link = "" + referral_share_url = "" + if config.referral_enabled and config.bot_public_username_normalized: + referral_link = ( + f"https://t.me/{config.bot_public_username_normalized}" + f"?start=ref_{referral_summary.referral_code}" + ) + share_text = ( + "Вас пригласили по реферальной ссылке. " + f"Перед оплатой введите код {referral_summary.referral_code} " + f"и получите скидку {max(config.referral_discount_percent, 0)}%." + ) + referral_share_url = ( + "https://t.me/share/url" + f"?url={quote_plus(referral_link)}" + f"&text={quote_plus(share_text)}" + ) + + plans = await payment_service.get_available_plans(telegram_id=telegram_id) + primary_plan = plans[0] if plans else None + admin_total_telegram_users = 0 + admin_total_cached_users = 0 + admin_active_cached_users = 0 + admin_pending_orders = 0 + admin_review_orders = 0 + admin_open_tickets = 0 + admin_pending_referral_bonuses = 0 + + if is_admin: + admin_stats = await sync_service.get_admin_stats() + payment_admin_stats = await payment_service.get_admin_stats() + admin_total_telegram_users = admin_stats.total_telegram_users + admin_total_cached_users = admin_stats.total_cached_users + admin_active_cached_users = admin_stats.active_cached_users + admin_pending_orders = payment_admin_stats.pending_orders + admin_review_orders = payment_admin_stats.review_orders + admin_pending_referral_bonuses = payment_admin_stats.pending_referral_bonuses + admin_open_tickets = await support_ticket_service.get_open_tickets_count() + + return PanelContext( + telegram_id=telegram_id, + display_name=display_name, + username=username, + language_code=language_code, + is_admin=is_admin, + users=users, + referral_enabled=config.referral_enabled, + referral_code=referral_summary.referral_code, + referral_count=referral_summary.total_invited, + applied_referral_code=( + referral_summary.applied_referral_code if config.referral_enabled else "" + ), + referral_link=referral_link, + referral_share_url=referral_share_url, + referral_recent_names=referral_summary.recent_names, + referral_discount_percent=max(config.referral_discount_percent, 0), + referral_bonus_days=max(config.referral_bonus_days, 0), + admin_total_telegram_users=admin_total_telegram_users, + admin_total_cached_users=admin_total_cached_users, + admin_active_cached_users=admin_active_cached_users, + admin_pending_orders=admin_pending_orders, + admin_review_orders=admin_review_orders, + admin_open_tickets=admin_open_tickets, + admin_pending_referral_bonuses=admin_pending_referral_bonuses, + payment_enabled=bool(primary_plan and primary_plan.is_ready), + payment_plan_title=primary_plan.title if primary_plan else "", + payment_plan_description=primary_plan.description if primary_plan else "", + payment_plan_price_stars=primary_plan.amount_rub if primary_plan else 0, + payment_plan_duration_days=primary_plan.duration_days if primary_plan else 0, + payment_plan_traffic_limit_bytes=primary_plan.traffic_limit_bytes if primary_plan else 0, + api_error=api_error, + ) + + def parse_start_referral_code(message: Message) -> str | None: + text = (message.text or "").strip() + parts = text.split(maxsplit=1) + if len(parts) < 2: + return None + + arg = parts[1].strip() + if not arg.startswith("ref_"): + return None + + raw_value = arg.removeprefix("ref_").strip().upper() + return raw_value if raw_value else None + + async def show_panel( + message: Message, + *, + context: PanelContext, + section: str, + ) -> None: + config = await get_runtime_config() + caption = render_panel_caption( + section=section, + context=context, + brand_name=config.bot_brand_name, + ) + keyboard = build_panel_keyboard( + section=section, + context=context, + support_url=config.bot_support_url, + terms_url=config.bot_terms_url, + payment_review_url=config.payment_review_link, + support_ticket_url=config.bot_support_ticket_link, + ) + + start_image_path = get_start_image_path(config) + if start_image_path is not None: + await message.answer_photo( + FSInputFile(str(start_image_path)), + caption=caption, + reply_markup=keyboard, + ) + return + + await message.answer(caption, reply_markup=keyboard) + + async def edit_message_content( + callback: CallbackQuery, + *, + text: str, + reply_markup, + ) -> None: + if callback.message is None: + await callback.answer() + return + + try: + if callback.message.photo or callback.message.document: + await callback.message.edit_caption(caption=text, reply_markup=reply_markup) + else: + await callback.message.edit_text(text, reply_markup=reply_markup) + except TelegramBadRequest as exc: + if "message is not modified" not in str(exc).lower(): + raise + + async def show_text_view( + callback: CallbackQuery, + *, + text: str, + reply_markup, + ) -> None: + if callback.message is None: + await callback.answer() + return + + try: + if callback.message.photo or callback.message.document: + await callback.message.answer(text, reply_markup=reply_markup) + else: + await callback.message.edit_text(text, reply_markup=reply_markup) + except TelegramBadRequest as exc: + if "message is not modified" not in str(exc).lower(): + raise + + async def update_panel( + callback: CallbackQuery, + *, + context: PanelContext, + section: str, + ) -> None: + config = await get_runtime_config() + caption = render_panel_caption( + section=section, + context=context, + brand_name=config.bot_brand_name, + ) + keyboard = build_panel_keyboard( + section=section, + context=context, + support_url=config.bot_support_url, + terms_url=config.bot_terms_url, + payment_review_url=config.payment_review_link, + support_ticket_url=config.bot_support_ticket_link, + ) + await edit_message_content(callback, text=caption, reply_markup=keyboard) + await callback.answer() + + async def send_lookup_result(message: Message, *, identifier: str) -> bool: + try: + user = await sync_service.sync_user_by_identifier( + identifier=identifier, + include_history=True, + ) + except RemnawaveApiError as exc: + await message.answer( + "Пользователь не найден или API вернул ошибку.\n" + f"Ошибка: {html.escape(exc.message)}" + ) + return False + + await message.answer( + format_user_card( + user=user.record, + squad_names=user.internal_squads, + recent_requests=user.recent_requests, + ) + ) + return True + + async def run_sync_all(message: Message) -> bool: + await message.answer("Запустил полную синхронизацию Remnawave -> MariaDB.") + + try: + total = await sync_service.sync_all_users(settings.sync_batch_size) + except RemnawaveApiError as exc: + await message.answer( + "Полная синхронизация завершилась с ошибкой.\n" + f"Ошибка: {html.escape(exc.message)}" + ) + return False + + await message.answer( + f"Полная синхронизация завершена. Обновлено пользователей: {total}" + ) + return True + + def admin_settings_callback(action: str, value: str | None = None) -> str: + return f"admin:{action}:{value}" if value else f"admin:{action}" + + def admin_users_page_callback(page: int) -> str: + return admin_settings_callback("user_list", str(max(page, 1))) + + def admin_user_profile_callback(*, telegram_id: int, page: int) -> str: + return f"admin:user_view:{telegram_id}:{max(page, 1)}" + + def compact_inline_label(value: str, *, limit: int = 30) -> str: + normalized = " ".join(value.split()) + if len(normalized) <= limit: + return normalized + return f"{normalized[: max(0, limit - 3)].rstrip()}..." + + def render_admin_settings_home_text(*, config: BotConfigSnapshot) -> str: + lines = [ + "⚙️ Настройки бота", + "", + "
Здесь меняются бизнес-настройки бота без правки .env и перезапуска.
", + "", + "Разделы", + ] + for section, label in BotConfigService.get_sections(): + spec_count = len(BotConfigService.get_specs_for_section(section)) + lines.append(f"• {html.escape(label)}: {spec_count}") + lines.extend( + [ + "", + "Секреты и инфраструктурные параметры по-прежнему остаются в .env.", + ] + ) + return "\n".join(lines) + + def build_admin_settings_home_keyboard(): + builder = InlineKeyboardBuilder() + for section, label in BotConfigService.get_sections(): + builder.button( + text=f"⚙️ {label}", + callback_data=admin_settings_callback("settings_section", section), + ) + builder.button( + text="↻ Обновить", + callback_data=admin_settings_callback("settings"), + ) + builder.button( + text="🛠 Админ-панель", + callback_data=f"panel:{PANEL_SECTION_ADMIN}", + ) + builder.adjust(2, 2, 2, 1, 1) + return builder.as_markup() + + def render_admin_settings_section_text( + *, + config: BotConfigSnapshot, + section: str, + ) -> str: + section_label = BotConfigService.get_section_label(section) + specs = BotConfigService.get_specs_for_section(section) + lines = [ + f"⚙️ {html.escape(section_label)}", + "", + ] + + for spec in specs: + current_value = BotConfigService.format_value(snapshot=config, key=spec.key) + lines.extend( + [ + f"{html.escape(spec.label)}", + f"{html.escape(current_value)}", + html.escape(spec.description), + "", + ] + ) + + lines.append("Нажмите на параметр ниже, чтобы изменить его.") + return "\n".join(lines) + + def build_admin_settings_section_keyboard(*, section: str): + builder = InlineKeyboardBuilder() + specs = BotConfigService.get_specs_for_section(section) + for spec in specs: + builder.button( + text=f"✏️ {spec.label}", + callback_data=admin_settings_callback("edit", spec.key), + ) + builder.button( + text="↻ Обновить", + callback_data=admin_settings_callback("settings_section", section), + ) + builder.button( + text="⬅️ Разделы", + callback_data=admin_settings_callback("settings"), + ) + builder.button( + text="🛠 Админ-панель", + callback_data=f"panel:{PANEL_SECTION_ADMIN}", + ) + builder.adjust(1) + return builder.as_markup() + + def render_admin_setting_prompt( + *, + config: BotConfigSnapshot, + key: str, + ) -> str: + spec = BotConfigService.get_spec(key) + if spec is None: + return "Неизвестная настройка." + + current_value = BotConfigService.format_value(snapshot=config, key=key) + return "\n".join( + [ + f"{html.escape(spec.label)}", + "", + html.escape(spec.prompt), + "", + f"Текущее значение: {html.escape(current_value)}", + "", + f"{html.escape(spec.description)}", + ] + ) + + def render_admin_users_page_text(*, page_data: AdminTelegramUsersPage) -> str: + lines = [ + "👤 Пользователи", + "", + ( + "
" + f"Всего пользователей: {page_data.total_items}\n" + f"Страница: {page_data.page}/{page_data.total_pages}" + "
" + ), + ] + + if not page_data.items: + lines.extend(["", "Пользователи ещё не появлялись в боте."]) + return "\n".join(lines) + + for index, item in enumerate( + page_data.items, + start=((page_data.page - 1) * page_data.page_size) + 1, + ): + username_text = f"@{item.username}" if item.username else "—" + badges: list[str] = [] + if item.is_admin: + badges.append("админ") + if item.is_blocked: + badges.append("заблокирован") + if not badges: + badges.append("пользователь") + + lines.extend( + [ + "", + ( + f"{index}. {html.escape(item.display_name)}\n" + "
" + f"ID: {item.telegram_id}\n" + f"Username: {html.escape(username_text)}\n" + f"Статус: {html.escape(', '.join(badges))}\n" + f"Последняя активность: {format_datetime(item.last_seen_at)}" + "
" + ), + ] + ) + + return "\n".join(lines) + + def build_admin_users_page_keyboard(*, page_data: AdminTelegramUsersPage): + builder = InlineKeyboardBuilder() + + for item in page_data.items: + button_label = item.display_name + if item.username: + button_label = f"{button_label} (@{item.username})" + else: + button_label = f"{button_label} • {item.telegram_id}" + builder.row( + InlineKeyboardButton( + text=f"👤 {compact_inline_label(button_label)}", + callback_data=admin_user_profile_callback( + telegram_id=item.telegram_id, + page=page_data.page, + ), + ) + ) + + pagination_buttons: list[InlineKeyboardButton] = [] + if page_data.page > 1: + pagination_buttons.append( + InlineKeyboardButton( + text="⬅️ Назад", + callback_data=admin_users_page_callback(page_data.page - 1), + ) + ) + pagination_buttons.append( + InlineKeyboardButton( + text=f"{page_data.page}/{page_data.total_pages}", + callback_data=admin_users_page_callback(page_data.page), + ) + ) + if page_data.page < page_data.total_pages: + pagination_buttons.append( + InlineKeyboardButton( + text="Вперёд ➡️", + callback_data=admin_users_page_callback(page_data.page + 1), + ) + ) + builder.row(*pagination_buttons) + builder.row( + InlineKeyboardButton( + text="↻ Обновить", + callback_data=admin_users_page_callback(page_data.page), + ), + InlineKeyboardButton( + text="🛠 Админ-панель", + callback_data=f"panel:{PANEL_SECTION_ADMIN}", + ), + ) + return builder.as_markup() + + def render_admin_user_profile_text(*, profile: AdminTelegramUserProfile) -> str: + user = profile.user + display_name = " ".join( + part for part in [user.first_name, user.last_name] if part + ).strip() or user.username or str(user.telegram_id) + username_text = f"@{user.username}" if user.username else "—" + referral_recent_names = ( + ", ".join(profile.recent_invited_names) + if profile.recent_invited_names + else "—" + ) + + lines = [ + f"👤 {html.escape(display_name)}", + "", + "Telegram-профиль", + ( + "
" + f"ID: {user.telegram_id}\n" + f"Username: {html.escape(username_text)}\n" + f"Имя: {html.escape(user.first_name or '—')}\n" + f"Фамилия: {html.escape(user.last_name or '—')}\n" + f"Язык: {html.escape(user.language_code or '—')}\n" + f"Админ: {'да' if user.is_admin else 'нет'}\n" + f"Заблокирован: {'да' if user.is_blocked else 'нет'}\n" + f"Создан: {format_datetime(user.created_at)}\n" + f"Последняя активность: {format_datetime(user.last_seen_at)}\n" + f"Обновлён: {format_datetime(user.updated_at)}" + "
" + ), + "", + "Реферальная система", + ( + "
" + f"Свой код: {html.escape(profile.referral_code or '—')}\n" + f"Применённый код: {html.escape(profile.applied_referral_code or '—')}\n" + f"Приглашено пользователей: {profile.invited_count}\n" + f"Последние приглашённые: {html.escape(referral_recent_names)}" + "
" + ), + "", + f"Доступы Remnawave: {len(profile.accesses)}", + ] + + if not profile.accesses: + lines.append("
У пользователя пока нет сохранённых доступов.
") + return "\n".join(lines) + + for index, access in enumerate(profile.accesses, start=1): + squads = ", ".join(sorted(access.internal_squads)) if access.internal_squads else "—" + lines.extend( + [ + "", + f"{index}. {html.escape(access.record.username)} {html.escape(access.record.status)}", + ( + "
" + f"UUID: {html.escape(access.record.rw_uuid)}\n" + f"Short UUID: {html.escape(access.record.short_uuid)}\n" + f"Истекает: {format_datetime_with_days_left(access.record.expire_at)}\n" + f"Трафик: {format_bytes(access.record.used_traffic_bytes)} / " + f"{format_traffic_limit(access.record.traffic_limit_bytes)}\n" + f"Группы: {html.escape(squads)}\n" + f"Email: {html.escape(access.record.email or '—')}\n" + f"Tag: {html.escape(access.record.tag or '—')}\n" + f"Последнее открытие: {format_datetime(access.record.sub_last_opened_at)}\n" + f"Синхронизирован: {format_datetime(access.record.synced_at)}\n" + f"Подписка: {html.escape(access.record.subscription_url)}" + "
" + ), + ] + ) + + return "\n".join(lines) + + def build_admin_user_profile_keyboard(*, profile: AdminTelegramUserProfile, telegram_id: int, page: int): + builder = InlineKeyboardBuilder() + + is_blocked = profile.user.is_blocked if profile else False + if is_blocked: + builder.row( + InlineKeyboardButton( + text="\U0001f513 Разблокировать", + callback_data=f"admin:unblock:{telegram_id}:{page}", + ) + ) + else: + builder.row( + InlineKeyboardButton( + text="\U0001f512 Заблокировать", + callback_data=f"admin:block:{telegram_id}:{page}", + ) + ) + + for access in profile.accesses: + short = access.record.short_uuid + status_upper = access.record.status.upper() + if status_upper == "DISABLED": + builder.row( + InlineKeyboardButton( + text=f"\u2705 \u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c {compact_inline_label(access.record.username, limit=16)}", + callback_data=f"admin:en_acc:{short}:{telegram_id}:{page}", + ) + ) + else: + builder.row( + InlineKeyboardButton( + text=f"\u26d4 \u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c {compact_inline_label(access.record.username, limit=16)}", + callback_data=f"admin:dis_acc:{short}:{telegram_id}:{page}", + ) + ) + builder.row( + InlineKeyboardButton( + text=f"\u23f1 \u0412\u0440\u0435\u043c\u044f {compact_inline_label(access.record.username, limit=16)}", + callback_data=f"admin:time_menu:{short}:{telegram_id}:{page}", + ) + ) + + builder.row( + InlineKeyboardButton( + text="\u21bb \u041e\u0431\u043d\u043e\u0432\u0438\u0442\u044c", + callback_data=admin_user_profile_callback( + telegram_id=telegram_id, + page=page, + ), + ), + InlineKeyboardButton( + text="\u2b05\ufe0f \u041a \u0441\u043f\u0438\u0441\u043a\u0443", + callback_data=admin_users_page_callback(page), + ), + ) + builder.row( + InlineKeyboardButton( + text="\U0001f6e0 \u0410\u0434\u043c\u0438\u043d-\u043f\u0430\u043d\u0435\u043b\u044c", + callback_data=f"panel:{PANEL_SECTION_ADMIN}", + ), + ) + return builder.as_markup() + + def build_time_adjust_keyboard(*, short_uuid: str, telegram_id: int, page: int): + builder = InlineKeyboardBuilder() + for days in [1, 5, 10, 15, 30]: + builder.button( + text=f"+{days} \u0434\u043d.", + callback_data=f"admin:at:+{days}:{short_uuid}:{telegram_id}:{page}", + ) + builder.adjust(5) + minus_row: list[InlineKeyboardButton] = [] + for days in [1, 5, 10, 15, 30]: + minus_row.append( + InlineKeyboardButton( + text=f"-{days} \u0434\u043d.", + callback_data=f"admin:at:-{days}:{short_uuid}:{telegram_id}:{page}", + ) + ) + builder.row(*minus_row) + builder.row( + InlineKeyboardButton( + text="\u2b05\ufe0f \u041d\u0430\u0437\u0430\u0434", + callback_data=admin_user_profile_callback( + telegram_id=telegram_id, + page=page, + ), + ) + ) + return builder.as_markup() + + def render_payment_menu_text( + *, + plans: list[PaymentPlan], + referral_code: str, + referral_enabled: bool, + referral_discount_percent: int, + has_existing_access: bool, + ) -> str: + lines = [ + "💳 Оплата подписки", + "", + ] + + if has_existing_access: + lines.append( + "
У вас уже есть доступ. После подтверждения оплаты бот автоматически продлит текущую подписку на выбранный срок.
" + ) + lines.append("") + + if referral_enabled: + lines.append( + "
Если у вас есть реферальный код, укажите его до оплаты. После выбора тарифа бот покажет реквизиты и попросит отправить чек.
" + ) + else: + lines.append( + "
Выберите тариф. После выбора бот покажет реквизиты и попросит отправить чек.
" + ) + + if referral_code: + lines.extend( + [ + "", + f"🎟 Активный код: {html.escape(referral_code)}", + f"Скидка: {max(referral_discount_percent, 0)}%", + ] + ) + + lines.extend(["", "Выберите тариф"]) + if not plans: + lines.append("
Покупка пока не настроена.
") + return "\n".join(lines) + + for plan in plans: + if plan.discount_percent > 0 and plan.original_amount_rub != plan.amount_rub: + price_text = ( + f"{plan.amount_rub} ₽ " + f"{plan.original_amount_rub} ₽" + ) + else: + price_text = f"{plan.amount_rub} ₽" + lines.append(f"• {plan.duration_days} дней — {price_text}") + + return "\n".join(lines) + + def build_payment_menu_keyboard( + *, + plans: list[PaymentPlan], + referral_code: str, + referral_enabled: bool, + ): + builder = InlineKeyboardBuilder() + if referral_enabled: + builder.button( + text="🎟 Реферальный код" if not referral_code else "🎟 Изменить рефкод", + callback_data="referral:apply", + ) + for plan in plans: + price_text = f"{plan.duration_days} дней · {plan.amount_rub} ₽" + builder.button( + text=price_text, + callback_data=payment_callback("select", plan.code), + ) + builder.button( + text="🏠 Главное меню", + callback_data=f"panel:{PANEL_SECTION_HOME}", + ) + builder.adjust(1) + return builder.as_markup() + + def build_payment_receipt_keyboard(*, order_uuid: str): + builder = InlineKeyboardBuilder() + builder.button( + text="📎 Отправить чек", + callback_data=payment_callback("receipt", order_uuid), + ) + builder.button(text="⬅️ К тарифам", callback_data=payment_callback("menu")) + builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}") + builder.adjust(1) + return builder.as_markup() + + def build_issued_access_keyboard(*, subscription_url: str): + builder = InlineKeyboardBuilder() + if subscription_url.startswith(("http://", "https://")): + builder.button(text="🔗 Открыть подписку", url=subscription_url) + builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}") + builder.adjust(1) + return builder.as_markup() + + async def send_issued_access_message( + bot: Bot, + *, + chat_id: int, + subscription_url: str, + expire_at, + traffic_limit_bytes: int, + is_renewal: bool = False, + ) -> None: + config = await get_runtime_config() + caption = format_issued_access( + subscription_url=subscription_url, + expire_at=expire_at, + traffic_limit_bytes=traffic_limit_bytes, + brand_name=config.bot_brand_name, + is_renewal=is_renewal, + ) + reply_markup = build_issued_access_keyboard(subscription_url=subscription_url) + + start_image_path = get_start_image_path(config) + if start_image_path is not None: + await bot.send_photo( + chat_id=chat_id, + photo=FSInputFile(str(start_image_path)), + caption=caption, + reply_markup=reply_markup, + ) + return + + await bot.send_message( + chat_id=chat_id, + text=caption, + reply_markup=reply_markup, + ) + + async def notify_referral_bonus( + bot: Bot, + *, + inviter_telegram_id: int, + bonus_days: int, + status: str, + expire_at=None, + total_applied_days: int = 0, + ) -> None: + lines = [ + "🎁 Реферальный бонус начислен", + "", + f"По вашему коду прошла подтверждённая оплата.", + f"Новый бонус: +{bonus_days} дней", + ] + if status == "APPLIED" and expire_at is not None: + if total_applied_days > bonus_days: + lines.append(f"Всего применено накопленных бонусов: +{total_applied_days} дней") + lines.extend( + [ + "", + f"Ваш доступ продлён до: {format_datetime(expire_at)}", + ] + ) + else: + lines.extend( + [ + "", + "Бонус сохранён и применится автоматически, когда у вас появится активный доступ.", + ] + ) + + await send_branded( + bot, + chat_id=inviter_telegram_id, + text="\n".join(lines), + ) + + def render_payment_order_text(*, order: CreatedPaymentOrder) -> str: + return payment_service.build_transfer_text(order=order) + + def build_review_keyboard(*, order_uuid: str): + builder = InlineKeyboardBuilder() + builder.button( + text="✅ Подтвердить", + callback_data=payment_callback("approve", order_uuid), + ) + builder.button( + text="❌ Отклонить", + callback_data=payment_callback("reject", order_uuid), + ) + builder.adjust(2) + return builder.as_markup() + + def render_review_text( + *, + order: StoredPaymentOrder, + user_message: Message, + referral_code: str, + ) -> str: + username_text = ( + f"@{html.escape(user_message.from_user.username)}" + if user_message.from_user and user_message.from_user.username + else "не указан" + ) + display_name = " ".join( + part + for part in [ + user_message.from_user.first_name if user_message.from_user else None, + user_message.from_user.last_name if user_message.from_user else None, + ] + if part + ).strip() or (user_message.from_user.full_name if user_message.from_user else "Пользователь") + receipt_note = (user_message.caption or user_message.text or "").strip() + + lines = [ + f"💸 НОВАЯ ОПЛАТА #{order.order_uuid[:8].upper()}", + "", + f"Тариф: {html.escape(order.plan_title)}", + f"Сумма: {order.amount_rub} ₽", + f"Пользователь: {html.escape(display_name)}", + f"Telegram ID: {order.telegram_id}", + f"Username: {username_text}", + f"Логин Remnawave: {html.escape(order.provision_username)}", + ] + if referral_code: + lines.append(f"Рефкод: {html.escape(referral_code)}") + if receipt_note: + lines.extend( + [ + "", + "Комментарий", + f"
{html.escape(receipt_note)}
", + ] + ) + lines.extend( + [ + "", + "Проверьте чек и выберите действие кнопками ниже.", + ] + ) + return "\n".join(lines) + + async def show_payment_menu_message( + message: Message, + *, + telegram_id: int, + ) -> None: + config = await get_runtime_config() + plans = await payment_service.get_available_plans(telegram_id=telegram_id) + has_existing_access = bool(await sync_service.get_cached_users_for_telegram(telegram_id)) + referral_code = ( + (await sync_service.get_referral_summary(telegram_id)).applied_referral_code + if config.referral_enabled + else "" + ) + await answer_branded( + message, + render_payment_menu_text( + plans=plans, + referral_code=referral_code, + referral_enabled=config.referral_enabled, + referral_discount_percent=config.referral_discount_percent, + has_existing_access=has_existing_access, + ), + reply_markup=build_payment_menu_keyboard( + plans=plans, + referral_code=referral_code, + referral_enabled=config.referral_enabled, + ), + ) + + async def show_payment_menu_callback(callback: CallbackQuery) -> None: + if callback.from_user is None: + await callback.answer() + return + + config = await get_runtime_config() + plans = await payment_service.get_available_plans(telegram_id=callback.from_user.id) + has_existing_access = bool( + await sync_service.get_cached_users_for_telegram(callback.from_user.id) + ) + referral_code = ( + (await sync_service.get_referral_summary(callback.from_user.id)).applied_referral_code + if config.referral_enabled + else "" + ) + await edit_message_content( + callback, + text=render_payment_menu_text( + plans=plans, + referral_code=referral_code, + referral_enabled=config.referral_enabled, + referral_discount_percent=config.referral_discount_percent, + has_existing_access=has_existing_access, + ), + reply_markup=build_payment_menu_keyboard( + plans=plans, + referral_code=referral_code, + referral_enabled=config.referral_enabled, + ), + ) + await callback.answer() + + async def send_ticket_to_support(message: Message, *, state: FSMContext) -> None: + if message.from_user is None: + await state.clear() + return + + config = await get_runtime_config() + support_chat_id = config.support_ticket_chat_id + if support_chat_id is None: + await message.answer( + "Канал для тикетов пока не настроен. Проверьте параметры " + "BOT_SUPPORT_TICKET_LINK или " + "BOT_SUPPORT_TICKET_CHAT_ID." + ) + await state.clear() + return + + raw_message_text = (message.text or message.caption or "").strip() + if not raw_message_text: + await message.answer( + "Для тикета нужен текст вопроса или проблемы.\n" + "Отправьте одно текстовое сообщение или подпись к файлу." + ) + return + + ticket_id = secrets.token_hex(4).upper() + username_text = ( + f"@{html.escape(message.from_user.username)}" + if message.from_user.username + else "не указан" + ) + display_name = " ".join( + part + for part in [message.from_user.first_name, message.from_user.last_name] + if part + ).strip() or message.from_user.full_name + + ticket_text = "\n".join( + [ + f"🎫 НОВЫЙ ТИКЕТ #{ticket_id}", + "", + f"ID: {ticket_id}", + f"Пользователь: {html.escape(display_name)}", + f"Telegram ID: {message.from_user.id}", + f"Username: {username_text}", + f"Создан: {format_datetime(message.date.replace(tzinfo=None))}", + "", + "Сообщение", + f"
{html.escape(raw_message_text)}
", + "", + "Ответьте реплаем на это сообщение, чтобы бот доставил ответ пользователю.", + ] + ) + + support_message = await message.bot.send_message( + chat_id=support_chat_id, + message_thread_id=config.support_ticket_message_thread_id, + text=ticket_text, + ) + + await support_ticket_service.create_ticket( + public_id=ticket_id, + telegram_id=message.from_user.id, + username=message.from_user.username, + display_name=display_name, + user_message=raw_message_text, + support_chat_id=support_chat_id, + support_thread_id=config.support_ticket_message_thread_id, + support_message_id=support_message.message_id, + ) + + await state.clear() + await answer_branded( + message, + f"🎫 Тикет #{ticket_id} отправлен в поддержку.\n" + "Ответ придёт сюда, как только его обработают." + ) + + async def send_receipt_for_review( + message: Message, + *, + order: StoredPaymentOrder, + referral_code: str, + ) -> None: + config = await get_runtime_config() + review_chat_id = config.payment_review_chat_id + if review_chat_id is None: + raise ValueError( + "Канал проверки оплат не настроен. Проверьте PAYMENT_REVIEW_LINK или PAYMENT_REVIEW_CHAT_ID." + ) + + review_text = render_review_text( + order=order, + user_message=message, + referral_code=referral_code, + ) + reply_markup = build_review_keyboard(order_uuid=order.order_uuid) + + if message.photo: + await message.bot.send_photo( + chat_id=review_chat_id, + message_thread_id=config.payment_review_message_thread_id, + photo=message.photo[-1].file_id, + caption=review_text, + reply_markup=reply_markup, + ) + return + + if message.document: + await message.bot.send_document( + chat_id=review_chat_id, + message_thread_id=config.payment_review_message_thread_id, + document=message.document.file_id, + caption=review_text, + reply_markup=reply_markup, + ) + return + + receipt_note = (message.text or message.caption or "").strip() + if not receipt_note: + raise ValueError("Отправьте фото, документ или текст с подтверждением оплаты.") + + await message.bot.send_message( + chat_id=review_chat_id, + message_thread_id=config.payment_review_message_thread_id, + text=review_text, + reply_markup=reply_markup, + ) + + async def append_review_status(callback: CallbackQuery, *, status_text: str) -> None: + if callback.message is None: + return + + source_text = callback.message.caption or callback.message.text or "" + updated_text = f"{source_text}\n\nСтатус: {status_text}" + if callback.message.photo or callback.message.document: + await callback.message.edit_caption(caption=updated_text, reply_markup=None) + else: + await callback.message.edit_text(updated_text, reply_markup=None) + + @router.message(CommandStart()) + @router.message(Command("menu")) + async def cmd_start(message: Message, state: FSMContext) -> None: + if not await ensure_sender(message): + return + + await clear_interactive_states(state) + incoming_referral_code = parse_start_referral_code(message) + + await sync_service.register_telegram_user( + telegram_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name, + language_code=message.from_user.language_code, + ) + + context = await load_panel_context( + telegram_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name, + language_code=message.from_user.language_code, + sync_remote=True, + ) + config = await get_runtime_config() + if incoming_referral_code and config.referral_enabled: + await message.answer( + "👥 Вас пригласили по реферальной ссылке.\n" + f"Чтобы получить скидку {max(config.referral_discount_percent, 0)}%, перед оплатой введите код " + f"{incoming_referral_code} в разделе " + "Реферальная система." + ) + await show_panel(message, context=context, section=PANEL_SECTION_HOME) + + @router.message(Command("buy")) + async def cmd_buy(message: Message, state: FSMContext) -> None: + if not await ensure_sender(message): + return + + await clear_interactive_states(state) + await show_payment_menu_message(message, telegram_id=message.from_user.id) + + @router.message(Command("paysupport")) + async def cmd_paysupport(message: Message, state: FSMContext) -> None: + if not await ensure_sender(message): + return + + await clear_interactive_states(state) + config = await get_runtime_config() + support_text = config.payment_support_text.strip() or "По вопросам оплаты напишите в поддержку." + await message.answer(html.escape(support_text)) + + @router.callback_query(F.data == "payment:buy") + @router.callback_query(F.data == "payment:menu") + async def on_payment_menu(callback: CallbackQuery, state: FSMContext) -> None: + await clear_interactive_states(state) + await show_payment_menu_callback(callback) + + @router.callback_query(F.data.startswith("payment:select:")) + async def on_payment_select(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None: + await callback.answer() + return + + await clear_interactive_states(state) + plan_code = (callback.data or "").split(":", maxsplit=2)[2] + + try: + order = await payment_service.create_order( + telegram_id=callback.from_user.id, + username=callback.from_user.username, + first_name=callback.from_user.first_name, + last_name=callback.from_user.last_name, + language_code=callback.from_user.language_code, + plan_code=plan_code, + ) + except ValueError as exc: + await callback.answer(str(exc), show_alert=True) + return + + await edit_message_content( + callback, + text=render_payment_order_text(order=order), + reply_markup=build_payment_receipt_keyboard(order_uuid=order.order_uuid), + ) + await callback.answer() + + @router.callback_query(F.data.startswith("payment:receipt:")) + async def on_payment_receipt_start(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None or callback.message is None: + await callback.answer() + return + + order_uuid = (callback.data or "").split(":", maxsplit=2)[2] + try: + order = await payment_service.get_order_for_user( + order_uuid=order_uuid, + telegram_id=callback.from_user.id, + ) + except ValueError as exc: + await callback.answer(str(exc), show_alert=True) + return + + if order.status == REVIEW_PAYMENT_STATUS: + await callback.answer("Чек уже отправлен на проверку.", show_alert=True) + return + if order.status == FULFILLED_PAYMENT_STATUS: + await callback.answer("Этот заказ уже подтверждён.", show_alert=True) + return + if order.status == REJECTED_PAYMENT_STATUS: + await callback.answer("Этот заказ уже отклонён. Создайте новый.", show_alert=True) + return + + await state.set_state(ManualPaymentStates.waiting_for_receipt) + await state.update_data(order_uuid=order_uuid) + await callback.message.answer( + "📎 Отправьте фото, скриншот, документ или текст с подтверждением перевода одним следующим сообщением." + ) + await callback.answer() + + @router.message(ManualPaymentStates.waiting_for_receipt, Command("cancel")) + @router.message(SupportTicketStates.waiting_for_message, Command("cancel")) + @router.message(ReferralCodeStates.waiting_for_code, Command("cancel")) + @router.message(AdminConfigStates.waiting_for_value, Command("cancel")) + async def cancel_interactive_flow(message: Message, state: FSMContext) -> None: + await state.clear() + await message.answer("Действие отменено.") + + @router.message(ManualPaymentStates.waiting_for_receipt) + async def on_payment_receipt_message(message: Message, state: FSMContext) -> None: + if message.from_user is None: + await state.clear() + return + + state_data = await state.get_data() + order_uuid = str(state_data.get("order_uuid") or "").strip() + if not order_uuid: + await state.clear() + await message.answer("Заказ не найден. Начните оплату заново.") + return + + try: + order = await payment_service.get_order_for_user( + order_uuid=order_uuid, + telegram_id=message.from_user.id, + ) + except ValueError as exc: + await state.clear() + await message.answer(html.escape(str(exc))) + return + + if order.status == REVIEW_PAYMENT_STATUS: + await state.clear() + await message.answer("Чек уже отправлен на проверку.") + return + if order.status == FULFILLED_PAYMENT_STATUS: + await state.clear() + await message.answer("Этот заказ уже подтверждён.") + return + if order.status == REJECTED_PAYMENT_STATUS: + await state.clear() + await message.answer("Этот заказ уже отклонён. Создайте новый платёж.") + return + + try: + order = await payment_service.mark_order_under_review( + order_uuid=order_uuid, + telegram_id=message.from_user.id, + ) + except ValueError as exc: + await message.answer(html.escape(str(exc))) + return + + try: + referral_code = await payment_service.get_referral_code_for_order(order_uuid=order_uuid) + await send_receipt_for_review( + message, + order=order, + referral_code=referral_code, + ) + except (TelegramBadRequest, TelegramForbiddenError, ValueError) as exc: + await payment_service.revert_order_to_pending( + order_uuid=order_uuid, + telegram_id=message.from_user.id, + ) + await message.answer(html.escape(str(exc))) + return + + await state.clear() + await message.answer( + "✅ Чек отправлен на проверку.\n" + "После подтверждения администратором доступ придёт сюда." + ) + + @router.callback_query(F.data == "referral:apply") + async def on_referral_apply(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None or callback.message is None: + await callback.answer() + return + + config = await get_runtime_config() + if not config.referral_enabled: + await callback.answer("Реферальная система сейчас отключена", show_alert=True) + return + + await state.set_state(ReferralCodeStates.waiting_for_code) + await callback.message.answer( + "🎟 Отправьте реферальный код одним следующим сообщением.\n\n" + "Код будет сохранён и применён перед оплатой.\n" + "Чтобы отменить ввод, отправьте /cancel." + ) + await callback.answer() + + @router.callback_query(F.data == "ticket:create") + async def on_ticket_create(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None or callback.message is None: + await callback.answer() + return + + config = await get_runtime_config() + if config.support_ticket_chat_id is None: + await callback.answer("Канал для тикетов не настроен", show_alert=True) + return + + await state.set_state(SupportTicketStates.waiting_for_message) + await callback.message.answer( + "🎫 Опишите ваш вопрос или проблему одним следующим сообщением.\n\n" + "Лучше отправить одно текстовое сообщение, чтобы тикет ушёл в поддержку в чистом виде.\n" + "Чтобы отменить создание тикета, отправьте /cancel." + ) + await callback.answer() + + @router.callback_query(F.data.startswith("panel:")) + async def on_panel_callback(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None: + await callback.answer() + return + + if await sync_service.is_user_blocked(callback.from_user.id): + await callback.answer("⛔ Ваш аккаунт заблокирован", show_alert=True) + return + + await clear_interactive_states(state) + + parts = (callback.data or "").split(":") + action = parts[1] if len(parts) > 1 else PANEL_SECTION_HOME + refresh = len(parts) > 2 and parts[2] == "refresh" + + valid_sections = { + PANEL_SECTION_HOME, + PANEL_SECTION_ADMIN, + PANEL_SECTION_SUBSCRIPTION, + PANEL_SECTION_REFERRAL, + PANEL_SECTION_SUPPORT, + PANEL_SECTION_SUPPORT_TICKET, + PANEL_SECTION_TERMS, + } + section = action if action in valid_sections else PANEL_SECTION_HOME + if section == PANEL_SECTION_ADMIN and not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + config = await get_runtime_config() + if section == PANEL_SECTION_REFERRAL and not config.referral_enabled: + await callback.answer("Реферальная система сейчас отключена", show_alert=True) + section = PANEL_SECTION_HOME + + context = await load_panel_context( + telegram_id=callback.from_user.id, + username=callback.from_user.username, + first_name=callback.from_user.first_name, + last_name=callback.from_user.last_name, + language_code=callback.from_user.language_code, + sync_remote=refresh, + ) + await update_panel(callback, context=context, section=section) + + @router.message(SupportTicketStates.waiting_for_message) + async def on_ticket_message(message: Message, state: FSMContext) -> None: + await send_ticket_to_support(message, state=state) + + @router.message(ReferralCodeStates.waiting_for_code) + async def on_referral_code_message(message: Message, state: FSMContext) -> None: + if message.from_user is None: + await state.clear() + return + + config = await get_runtime_config() + if not config.referral_enabled: + await state.clear() + await message.answer("Реферальная система сейчас отключена.") + return + + raw_code = (message.text or "").strip().upper() + if not raw_code: + await message.answer("Отправьте реферальный код текстом.") + return + + try: + applied_code = await sync_service.apply_referral_code( + telegram_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name, + language_code=message.from_user.language_code, + referral_code=raw_code, + ) + except ValueError as exc: + await message.answer(html.escape(str(exc))) + return + + await state.clear() + await message.answer( + f"✅ Реферальный код {applied_code} сохранён.\n" + f"Скидка {max(config.referral_discount_percent, 0)}% будет применена перед оплатой." + ) + await show_payment_menu_message(message, telegram_id=message.from_user.id) + + @router.callback_query(F.data == "admin:lookup") + async def on_admin_lookup(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None or callback.message is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + await state.set_state(AdminLookupStates.waiting_for_identifier) + await callback.message.answer( + "🔍 Отправьте идентификатор пользователя следующим сообщением.\n\n" + "Поддерживаются: uuid, id, username, short UUID.\n" + "Чтобы отменить действие, отправьте /cancel." + ) + await callback.answer() + + @router.callback_query(F.data == "admin:sync_all") + async def on_admin_sync_all(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None or callback.message is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + await clear_interactive_states(state) + await callback.answer("Синхронизация запущена") + await run_sync_all(callback.message) + + context = await load_panel_context( + telegram_id=callback.from_user.id, + username=callback.from_user.username, + first_name=callback.from_user.first_name, + last_name=callback.from_user.last_name, + language_code=callback.from_user.language_code, + sync_remote=False, + ) + await update_panel(callback, context=context, section=PANEL_SECTION_ADMIN) + + @router.callback_query(F.data == "admin:settings") + async def on_admin_settings(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + await clear_interactive_states(state) + config = await get_runtime_config() + await show_branded_view( + callback, + text=render_admin_settings_home_text(config=config), + reply_markup=build_admin_settings_home_keyboard(), + ) + await callback.answer() + + @router.callback_query(F.data.startswith("admin:settings_section:")) + async def on_admin_settings_section(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + await clear_interactive_states(state) + section = (callback.data or "").split(":", maxsplit=2)[2] + if not BotConfigService.get_specs_for_section(section): + await callback.answer("Раздел не найден", show_alert=True) + return + + config = await get_runtime_config() + await show_branded_view( + callback, + text=render_admin_settings_section_text(config=config, section=section), + reply_markup=build_admin_settings_section_keyboard(section=section), + ) + await callback.answer() + + @router.callback_query(F.data == "admin:users") + async def on_admin_users(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + await clear_interactive_states(state) + page_data = await sync_service.get_admin_telegram_users_page(page=1) + await show_branded_view( + callback, + text=render_admin_users_page_text(page_data=page_data), + reply_markup=build_admin_users_page_keyboard(page_data=page_data), + ) + await callback.answer() + + @router.callback_query(F.data.startswith("admin:user_list:")) + async def on_admin_users_page(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + await clear_interactive_states(state) + raw_page = (callback.data or "").split(":", maxsplit=2)[2] + try: + page = int(raw_page) + except ValueError: + page = 1 + + page_data = await sync_service.get_admin_telegram_users_page(page=page) + await show_branded_view( + callback, + text=render_admin_users_page_text(page_data=page_data), + reply_markup=build_admin_users_page_keyboard(page_data=page_data), + ) + await callback.answer() + + @router.callback_query(F.data.startswith("admin:user_view:")) + async def on_admin_user_profile(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + await clear_interactive_states(state) + parts = (callback.data or "").split(":") + if len(parts) < 4: + await callback.answer("Некорректный пользователь", show_alert=True) + return + + try: + telegram_id = int(parts[2]) + page = int(parts[3]) + except ValueError: + await callback.answer("Некорректный пользователь", show_alert=True) + return + + profile = await sync_service.get_admin_telegram_user_profile(telegram_id=telegram_id) + if profile is None: + await callback.answer("Пользователь не найден", show_alert=True) + return + + await show_branded_view( + callback, + text=render_admin_user_profile_text(profile=profile), + reply_markup=build_admin_user_profile_keyboard( + profile=profile, + telegram_id=telegram_id, + page=page, + ), + ) + await callback.answer() + + # ── Admin: Block / Unblock user ────────────────────────────────── + + @router.callback_query(F.data.startswith("admin:block:")) + async def on_admin_block_user(callback: CallbackQuery) -> None: + if callback.from_user is None or callback.message is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + parts = callback.data.split(":") + if len(parts) < 4: + await callback.answer() + return + + try: + target_telegram_id = int(parts[2]) + page = int(parts[3]) + except ValueError: + await callback.answer("Некорректные данные", show_alert=True) + return + + await sync_service.block_telegram_user(target_telegram_id) + + # Auto-disable all Remnawave accesses + try: + remote_users = await sync_service._remnawave_client.get_users_by_telegram_id( + target_telegram_id + ) + for ru in remote_users: + if ru.status.upper() != "DISABLED": + await sync_service._remnawave_client.update_user( + {"uuid": str(ru.uuid), "status": "DISABLED"} + ) + except RemnawaveApiError: + pass + + try: + await sync_service.refresh_cached_users_for_telegram( + telegram_id=target_telegram_id, + ) + except RemnawaveApiError: + pass + + profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id) + if profile is None: + await callback.answer("Пользователь не найден", show_alert=True) + return + + await show_branded_view( + callback, + text=render_admin_user_profile_text(profile=profile), + reply_markup=build_admin_user_profile_keyboard( + profile=profile, + telegram_id=target_telegram_id, + page=page, + ), + ) + await callback.answer("🔒 Пользователь заблокирован", show_alert=True) + + @router.callback_query(F.data.startswith("admin:unblock:")) + async def on_admin_unblock_user(callback: CallbackQuery) -> None: + if callback.from_user is None or callback.message is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + parts = callback.data.split(":") + if len(parts) < 4: + await callback.answer() + return + + try: + target_telegram_id = int(parts[2]) + page = int(parts[3]) + except ValueError: + await callback.answer("Некорректные данные", show_alert=True) + return + + await sync_service.unblock_telegram_user(target_telegram_id) + + # Auto-enable all Remnawave accesses + try: + remote_users = await sync_service._remnawave_client.get_users_by_telegram_id( + target_telegram_id + ) + for ru in remote_users: + if ru.status.upper() == "DISABLED": + await sync_service._remnawave_client.update_user( + {"uuid": str(ru.uuid), "status": "ACTIVE"} + ) + except RemnawaveApiError: + pass + + try: + await sync_service.refresh_cached_users_for_telegram( + telegram_id=target_telegram_id, + ) + except RemnawaveApiError: + pass + + profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id) + if profile is None: + await callback.answer("Пользователь не найден", show_alert=True) + return + + await show_branded_view( + callback, + text=render_admin_user_profile_text(profile=profile), + reply_markup=build_admin_user_profile_keyboard( + profile=profile, + telegram_id=target_telegram_id, + page=page, + ), + ) + await callback.answer("🔓 Пользователь разблокирован", show_alert=True) + + # ── Admin: Disable / Enable individual Remnawave access ────────── + + @router.callback_query(F.data.startswith("admin:dis_acc:")) + async def on_admin_disable_access(callback: CallbackQuery) -> None: + if callback.from_user is None or callback.message is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + parts = callback.data.split(":") + if len(parts) < 5: + await callback.answer() + return + + short_uuid = parts[2] + try: + target_telegram_id = int(parts[3]) + page = int(parts[4]) + except ValueError: + await callback.answer("Некорректные данные", show_alert=True) + return + + try: + rw_user = await sync_service._remnawave_client.get_user_by_short_uuid(short_uuid) + await sync_service._remnawave_client.update_user( + {"uuid": str(rw_user.uuid), "status": "DISABLED"} + ) + except RemnawaveApiError as exc: + await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True) + return + + try: + await sync_service.refresh_cached_users_for_telegram(telegram_id=target_telegram_id) + except RemnawaveApiError: + pass + + profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id) + if profile is None: + await callback.answer("Пользователь не найден", show_alert=True) + return + + await show_branded_view( + callback, + text=render_admin_user_profile_text(profile=profile), + reply_markup=build_admin_user_profile_keyboard( + profile=profile, telegram_id=target_telegram_id, page=page, + ), + ) + await callback.answer("⛔ Доступ отключён", show_alert=True) + + @router.callback_query(F.data.startswith("admin:en_acc:")) + async def on_admin_enable_access(callback: CallbackQuery) -> None: + if callback.from_user is None or callback.message is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + parts = callback.data.split(":") + if len(parts) < 5: + await callback.answer() + return + + short_uuid = parts[2] + try: + target_telegram_id = int(parts[3]) + page = int(parts[4]) + except ValueError: + await callback.answer("Некорректные данные", show_alert=True) + return + + try: + rw_user = await sync_service._remnawave_client.get_user_by_short_uuid(short_uuid) + await sync_service._remnawave_client.update_user( + {"uuid": str(rw_user.uuid), "status": "ACTIVE"} + ) + except RemnawaveApiError as exc: + await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True) + return + + try: + await sync_service.refresh_cached_users_for_telegram(telegram_id=target_telegram_id) + except RemnawaveApiError: + pass + + profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id) + if profile is None: + await callback.answer("Пользователь не найден", show_alert=True) + return + + await show_branded_view( + callback, + text=render_admin_user_profile_text(profile=profile), + reply_markup=build_admin_user_profile_keyboard( + profile=profile, telegram_id=target_telegram_id, page=page, + ), + ) + await callback.answer("✅ Доступ включён", show_alert=True) + + # ── Admin: Time adjustment menu & actions ──────────────────────── + + @router.callback_query(F.data.startswith("admin:time_menu:")) + async def on_admin_time_menu(callback: CallbackQuery) -> None: + if callback.from_user is None or callback.message is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + parts = callback.data.split(":") + if len(parts) < 5: + await callback.answer() + return + + short_uuid = parts[2] + try: + target_telegram_id = int(parts[3]) + page = int(parts[4]) + except ValueError: + await callback.answer("Некорректные данные", show_alert=True) + return + + try: + rw_user = await sync_service._remnawave_client.get_user_by_short_uuid(short_uuid) + except RemnawaveApiError as exc: + await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True) + return + + text = ( + f"⏱ Изменение срока подписки\n\n" + f"Пользователь: {html.escape(rw_user.username)}\n" + f"Текущий срок: {format_datetime_with_days_left(rw_user.expire_at)}\n\n" + f"Выберите количество дней для добавления или снятия:" + ) + + await show_branded_view( + callback, + text=text, + reply_markup=build_time_adjust_keyboard( + short_uuid=short_uuid, + telegram_id=target_telegram_id, + page=page, + ), + ) + await callback.answer() + + @router.callback_query(F.data.startswith("admin:at:")) + async def on_admin_adjust_time(callback: CallbackQuery) -> None: + if callback.from_user is None or callback.message is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + + parts = callback.data.split(":") + # admin:at:+5:shortUuid:telegramId:page + if len(parts) < 6: + await callback.answer() + return + + days_str = parts[2] + short_uuid = parts[3] + try: + target_telegram_id = int(parts[4]) + page = int(parts[5]) + days = int(days_str) + except ValueError: + await callback.answer("Некорректные данные", show_alert=True) + return + + from datetime import timedelta, timezone + + try: + rw_user = await sync_service._remnawave_client.get_user_by_short_uuid(short_uuid) + except RemnawaveApiError as exc: + await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True) + return + + current_expire = rw_user.expire_at + if current_expire.tzinfo is None: + from datetime import timezone as tz + current_expire = current_expire.replace(tzinfo=tz.utc) + + from datetime import datetime as dt + + now = dt.now(timezone.utc) + + if days > 0: + new_expire = max(current_expire, now) + timedelta(days=days) + else: + new_expire = current_expire + timedelta(days=days) + if new_expire < now: + new_expire = now + + try: + await sync_service._remnawave_client.update_user( + { + "uuid": str(rw_user.uuid), + "expireAt": new_expire.isoformat().replace("+00:00", "Z"), + } + ) + except RemnawaveApiError as exc: + await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True) + return + + try: + await sync_service.refresh_cached_users_for_telegram(telegram_id=target_telegram_id) + except RemnawaveApiError: + pass + + sign = "+" if days > 0 else "" + await callback.answer(f"✅ {sign}{days} дн. применено", show_alert=True) + + # Re-fetch and show updated profile + profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id) + if profile is None: + return + + await show_branded_view( + callback, + text=render_admin_user_profile_text(profile=profile), + reply_markup=build_admin_user_profile_keyboard( + profile=profile, telegram_id=target_telegram_id, page=page, + ), + ) + + @router.callback_query(F.data.startswith("admin:edit:")) + async def on_admin_edit_setting(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None or callback.message is None: + await callback.answer() + return + if not settings.is_admin(callback.from_user.id): + await callback.answer("Недостаточно прав", show_alert=True) + return + if not isinstance(bot_config_service, BotConfigService): + await callback.answer("Редактирование сейчас недоступно", show_alert=True) + return + + key = (callback.data or "").split(":", maxsplit=2)[2] + spec = BotConfigService.get_spec(key) + if spec is None: + await callback.answer("Настройка не найдена", show_alert=True) + return + + config = await get_runtime_config() + await state.set_state(AdminConfigStates.waiting_for_value) + await state.update_data(config_key=key, config_section=spec.section) + await callback.message.answer( + render_admin_setting_prompt(config=config, key=key), + reply_markup=build_admin_settings_section_keyboard(section=spec.section), + ) + await callback.answer() + + @router.message(AdminLookupStates.waiting_for_identifier) + async def on_admin_lookup_message(message: Message, state: FSMContext) -> None: + if not await ensure_sender(message): + await state.clear() + return + if not settings.is_admin(message.from_user.id): + await state.clear() + await message.answer("Недостаточно прав.") + return + + identifier = (message.text or "").strip() + if not identifier: + await message.answer("Отправьте идентификатор пользователя текстом.") + return + + found = await send_lookup_result(message, identifier=identifier) + if found: + await state.clear() + + @router.message(AdminConfigStates.waiting_for_value) + async def on_admin_setting_value(message: Message, state: FSMContext) -> None: + if not await ensure_sender(message): + await state.clear() + return + if not settings.is_admin(message.from_user.id): + await state.clear() + await message.answer("Недостаточно прав.") + return + if not isinstance(bot_config_service, BotConfigService): + await state.clear() + await message.answer("Редактирование настроек сейчас недоступно.") + return + + state_data = await state.get_data() + key = str(state_data.get("config_key") or "").strip() + section = str(state_data.get("config_section") or "").strip() + spec = BotConfigService.get_spec(key) + if not key or spec is None: + await state.clear() + await message.answer("Настройка не найдена. Откройте админ-панель заново.") + return + + raw_value = (message.text or message.caption or "").strip() + if not raw_value: + await message.answer("Отправьте новое значение текстом.") + return + + try: + await bot_config_service.update_setting( + key=key, + raw_value=raw_value, + updated_by_telegram_id=message.from_user.id, + ) + except ValueError as exc: + await message.answer(html.escape(str(exc))) + return + + await state.clear() + config = await get_runtime_config() + current_value = BotConfigService.format_value(snapshot=config, key=key) + await message.answer( + "\n".join( + [ + f"✅ Настройка {html.escape(spec.label)} сохранена.", + f"Новое значение: {html.escape(current_value)}", + ] + ), + reply_markup=build_admin_settings_section_keyboard(section=section or spec.section), + ) + await message.answer( + render_admin_settings_section_text(config=config, section=section or spec.section), + reply_markup=build_admin_settings_section_keyboard(section=section or spec.section), + ) + + if True: + + @router.callback_query(F.data.startswith("payment:approve:")) + async def on_payment_approve(callback: CallbackQuery) -> None: + if callback.from_user is None: + await callback.answer() + return + + if not ( + settings.is_admin(callback.from_user.id) + or settings.is_moderator(callback.from_user.id) + ): + await callback.answer("Недостаточно прав", show_alert=True) + return + + order_uuid = (callback.data or "").split(":", maxsplit=2)[2] + try: + access = await payment_service.approve_order(order_uuid=order_uuid) + except (ValueError, RemnawaveApiError) as exc: + await callback.answer(str(exc), show_alert=True) + return + + try: + await send_issued_access_message( + callback.bot, + chat_id=access.telegram_id, + subscription_url=access.subscription_url, + expire_at=access.expire_at, + traffic_limit_bytes=access.traffic_limit_bytes, + is_renewal=access.is_renewal, + ) + except TelegramForbiddenError: + if access.referral_bonus is not None: + try: + await notify_referral_bonus( + callback.bot, + inviter_telegram_id=access.referral_bonus.inviter_telegram_id, + bonus_days=access.referral_bonus.bonus_days, + status=access.referral_bonus.status, + expire_at=access.referral_bonus.expire_at, + total_applied_days=access.referral_bonus.total_applied_days, + ) + except TelegramForbiddenError: + pass + await append_review_status(callback, status_text="✅ подтверждено, бот у пользователя недоступен") + await callback.answer( + "Доступ создан, но пользователь заблокировал бота.", + show_alert=True, + ) + return + + if access.referral_bonus is not None: + try: + await notify_referral_bonus( + callback.bot, + inviter_telegram_id=access.referral_bonus.inviter_telegram_id, + bonus_days=access.referral_bonus.bonus_days, + status=access.referral_bonus.status, + expire_at=access.referral_bonus.expire_at, + total_applied_days=access.referral_bonus.total_applied_days, + ) + except TelegramForbiddenError: + pass + + await append_review_status(callback, status_text="✅ подтверждено") + await callback.answer("Оплата подтверждена") + + @router.callback_query(F.data.startswith("payment:reject:")) + async def on_payment_reject(callback: CallbackQuery) -> None: + if callback.from_user is None: + await callback.answer() + return + + if not ( + settings.is_admin(callback.from_user.id) + or settings.is_moderator(callback.from_user.id) + ): + await callback.answer("Недостаточно прав", show_alert=True) + return + + order_uuid = (callback.data or "").split(":", maxsplit=2)[2] + config = await get_runtime_config() + try: + order = await payment_service.reject_order( + order_uuid=order_uuid, + reason=config.payment_support_text.strip() or "Платёж отклонён", + ) + except ValueError as exc: + await callback.answer(str(exc), show_alert=True) + return + + try: + await send_branded( + callback.bot, + chat_id=order.telegram_id, + text=( + "Платёж не подтверждён\n\n" + "Проверьте реквизиты и чек. " + f"{html.escape(config.payment_support_text.strip() or 'Если это ошибка, обратитесь в поддержку.')}" + ), + ) + except TelegramForbiddenError: + await append_review_status(callback, status_text="❌ отклонено, бот у пользователя недоступен") + await callback.answer( + "Не удалось уведомить пользователя: бот недоступен.", + show_alert=True, + ) + return + + await append_review_status(callback, status_text="❌ отклонено") + await callback.answer("Оплата отклонена") + + if True: + + @router.message(F.reply_to_message) + async def on_support_reply(message: Message) -> None: + if message.reply_to_message is None or message.from_user is None: + return + + if not ( + settings.is_admin(message.from_user.id) + or settings.is_moderator(message.from_user.id) + ): + await message.reply("Недостаточно прав для ответа на тикеты.") + return + + config = await get_runtime_config() + if config.support_ticket_chat_id is None or message.chat.id != config.support_ticket_chat_id: + return + + reply_text = (message.text or message.caption or "").strip() + if not reply_text: + return + + ticket = await support_ticket_service.get_ticket_by_support_message( + support_chat_id=message.chat.id, + support_message_id=message.reply_to_message.message_id, + ) + if ticket is None: + return + + try: + await send_branded( + message.bot, + chat_id=ticket.telegram_id, + text=( + f"🆘 Ответ по тикету #{ticket.public_id}\n\n" + f"{html.escape(reply_text)}" + ), + ) + except TelegramForbiddenError: + await message.reply( + "Не удалось доставить ответ: пользователь заблокировал бота или недоступен." + ) + return + + await support_ticket_service.mark_answered(ticket_id=ticket.id) + await message.reply(f"Ответ по тикету #{ticket.public_id} отправлен пользователю.") + + @router.message(Command("help")) + async def cmd_help(message: Message) -> None: + if not await ensure_sender(message): + return + + await answer_branded( + message, + build_help_text(is_admin=settings.is_admin(message.from_user.id)) + ) + + @router.message(Command("sync")) + async def cmd_sync(message: Message) -> None: + if not await ensure_sender(message): + return + + try: + users = await sync_service.sync_users_for_telegram( + telegram_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name, + language_code=message.from_user.language_code, + ) + except RemnawaveApiError as exc: + await message.answer( + "Не удалось обновить данные из Remnawave.\n" + f"Ошибка: {html.escape(exc.message)}" + ) + return + + if not users: + await message.answer( + "Синхронизация завершена, но связанных аккаунтов не найдено." + ) + return + + context = await load_panel_context( + telegram_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name, + language_code=message.from_user.language_code, + sync_remote=False, + ) + await show_panel(message, context=context, section=PANEL_SECTION_HOME) + + @router.message(Command("link")) + async def cmd_link(message: Message, command: CommandObject) -> None: + if not await ensure_sender(message): + return + + short_uuid = (command.args or "").strip() + if not short_uuid: + await message.answer( + "Укажите short_uuid из вашей ссылки подписки.\n" + "Пример значения: AbCdEF12" + ) + return + + try: + await sync_service.link_user_by_short_uuid( + short_uuid=short_uuid, + telegram_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name, + language_code=message.from_user.language_code, + ) + except ValueError as exc: + await message.answer(html.escape(str(exc))) + return + except RemnawaveApiError as exc: + await message.answer( + "Не удалось привязать аккаунт.\n" + f"Ошибка API: {html.escape(exc.message)}" + ) + return + + context = await load_panel_context( + telegram_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name, + language_code=message.from_user.language_code, + sync_remote=False, + ) + await message.answer("Аккаунт успешно привязан к вашему Telegram.") + await show_panel(message, context=context, section=PANEL_SECTION_SUBSCRIPTION) + + @router.message(Command("lookup")) + async def cmd_lookup(message: Message, command: CommandObject) -> None: + if not await ensure_sender(message): + return + + if not settings.is_admin(message.from_user.id): + await message.answer("Эта команда доступна только администраторам.") + return + + identifier = (command.args or "").strip() + if not identifier: + await message.answer( + "Укажите идентификатор пользователя.\n" + "Поддерживаются: uuid, id, username, short_uuid.\n" + "Пример: /lookup 9b488e20-2a1f-4d69-b04d-c7b7f435f1a5" + ) + return + + await send_lookup_result(message, identifier=identifier) + + @router.message(Command("sync_all")) + async def cmd_sync_all(message: Message) -> None: + if not await ensure_sender(message): + return + + if not settings.is_admin(message.from_user.id): + await message.answer("Эта команда доступна только администраторам.") + return + + await run_sync_all(message) + + return router diff --git a/app/bot/ui/__init__.py b/app/bot/ui/__init__.py new file mode 100644 index 0000000..31c7698 --- /dev/null +++ b/app/bot/ui/__init__.py @@ -0,0 +1 @@ +"""Telegram bot UI helpers.""" diff --git a/app/bot/ui/panel.py b/app/bot/ui/panel.py new file mode 100644 index 0000000..ee32f0a --- /dev/null +++ b/app/bot/ui/panel.py @@ -0,0 +1,595 @@ +from __future__ import annotations + +import html +import math +from dataclasses import dataclass +from datetime import datetime + +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from app.services.sync_service import CachedUserView +from app.utils.formatters import format_bytes, format_datetime, format_payment_plan, format_traffic_limit + +PANEL_SECTION_HOME = "home" +PANEL_SECTION_ADMIN = "admin" +PANEL_SECTION_SUBSCRIPTION = "subscription" +PANEL_SECTION_REFERRAL = "referral" +PANEL_SECTION_SUPPORT = "support" +PANEL_SECTION_SUPPORT_TICKET = "support_ticket" +PANEL_SECTION_TERMS = "terms" + + +@dataclass(slots=True) +class PanelContext: + telegram_id: int + display_name: str + username: str | None + language_code: str | None + is_admin: bool + users: list[CachedUserView] + referral_enabled: bool = True + referral_code: str = "" + referral_count: int = 0 + applied_referral_code: str = "" + referral_link: str = "" + referral_share_url: str = "" + referral_recent_names: list[str] | None = None + referral_discount_percent: int = 0 + referral_bonus_days: int = 0 + admin_total_telegram_users: int = 0 + admin_total_cached_users: int = 0 + admin_active_cached_users: int = 0 + admin_pending_orders: int = 0 + admin_review_orders: int = 0 + admin_open_tickets: int = 0 + admin_pending_referral_bonuses: int = 0 + payment_enabled: bool = False + payment_plan_title: str = "" + payment_plan_description: str = "" + payment_plan_price_stars: int = 0 + payment_plan_duration_days: int = 0 + payment_plan_traffic_limit_bytes: int = 0 + api_error: str | None = None + + +def _callback(section: str, *, refresh: bool = False) -> str: + return f"panel:{section}:refresh" if refresh else f"panel:{section}" + + +def _ticket_callback(action: str) -> str: + return f"ticket:{action}" + + +def _referral_callback(action: str) -> str: + return f"referral:{action}" + + +def _payment_callback(action: str) -> str: + return f"payment:{action}" + + +def _admin_callback(action: str) -> str: + return f"admin:{action}" + + +def _status_badge(status: str) -> str: + mapping = { + "ACTIVE": "🟢 Активна", + "LIMITED": "🟡 Ограничена", + "DISABLED": "⛔ Отключена", + "EXPIRED": "🔴 Истекла", + } + return mapping.get(status.upper(), f"⚪ {html.escape(status)}") + + +def _active_users_count(users: list[CachedUserView]) -> int: + return sum(1 for user in users if user.record.status.upper() == "ACTIVE") + +def _primary_user(users: list[CachedUserView]) -> CachedUserView | None: + for user in users: + if user.record.status.upper() == "ACTIVE": + return user + return users[0] if users else None + + +def _home_subscription_status(users: list[CachedUserView]) -> str: + return "Активна" if _active_users_count(users) > 0 else "Не активна" + + +def _days_left_text(user: CachedUserView | None) -> str: + if user is None or user.record.expire_at is None: + return "—" + + seconds_left = (user.record.expire_at - datetime.now()).total_seconds() + if seconds_left <= 0: + return "0 дней" + + days_left = math.ceil(seconds_left / 86400) + return f"{days_left} дней" + + +def _traffic_summary_text(user: CachedUserView | None) -> str: + if user is None: + return "—" + + return ( + f"{format_bytes(user.record.used_traffic_bytes)} / " + f"{format_traffic_limit(user.record.traffic_limit_bytes)}" + ) + + +def build_panel_keyboard( + *, + section: str, + context: PanelContext, + support_url: str = "", + terms_url: str = "", + payment_review_url: str = "", + support_ticket_url: str = "", +) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + primary_user = _primary_user(context.users) + + if section == PANEL_SECTION_HOME: + has_sub_link = bool( + primary_user + and primary_user.record.subscription_url.startswith(("http://", "https://")) + ) + if has_sub_link: + builder.button( + text="🔗 Открыть подписку", + url=primary_user.record.subscription_url, + ) + builder.button( + text="💳 Подписка", + callback_data=_payment_callback("menu"), + ) + if context.is_admin: + builder.button( + text="🛠 Админ-панель", + callback_data=_callback(PANEL_SECTION_ADMIN), + ) + if context.referral_enabled: + builder.button( + text="👥 Реферальная система", + callback_data=_callback(PANEL_SECTION_REFERRAL), + ) + builder.button( + text="🆘 Поддержка", + callback_data=_callback(PANEL_SECTION_SUPPORT), + ) + if terms_url: + builder.button(text="📜 Условия использования", url=terms_url) + else: + builder.button( + text="📜 Условия использования", + callback_data=_callback(PANEL_SECTION_TERMS), + ) + if has_sub_link: + if context.is_admin and context.referral_enabled: + builder.adjust(1, 1, 1, 2, 1) + elif context.is_admin: + builder.adjust(1, 1, 1, 1, 1) + else: + if context.referral_enabled: + builder.adjust(1, 1, 2, 1) + else: + builder.adjust(1, 1, 1, 1) + else: + if context.is_admin and context.referral_enabled: + builder.adjust(1, 1, 2, 1) + elif context.is_admin: + builder.adjust(1, 1, 1, 1) + else: + if context.referral_enabled: + builder.adjust(1, 2, 1) + else: + builder.adjust(1, 1, 1) + return builder.as_markup() + + if section == PANEL_SECTION_ADMIN: + if not context.is_admin: + builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME)) + builder.adjust(1) + return builder.as_markup() + + builder.button( + text="🔍 Найти пользователя", + callback_data=_admin_callback("lookup"), + ) + builder.button( + text="👤 Пользователи", + callback_data=_admin_callback("users"), + ) + builder.button( + text="🔄 Полная синхронизация", + callback_data=_admin_callback("sync_all"), + ) + builder.button( + text="⚙️ Настройки", + callback_data=_admin_callback("settings"), + ) + if payment_review_url.strip(): + builder.button(text="💸 Очередь оплат", url=payment_review_url.strip()) + if support_ticket_url.strip(): + builder.button(text="🎫 Тикеты", url=support_ticket_url.strip()) + builder.button( + text="↻ Обновить", + callback_data=_callback(PANEL_SECTION_ADMIN, refresh=True), + ) + builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME)) + builder.adjust(2, 2, 2, 2) + return builder.as_markup() + + if section == PANEL_SECTION_SUBSCRIPTION: + if context.payment_enabled: + builder.button( + text="💳 Продлить подписку" if context.users else "💳 Купить подписку", + callback_data=_payment_callback("buy"), + ) + if primary_user and primary_user.record.subscription_url.startswith(("http://", "https://")): + builder.button( + text="🔗 Открыть подписку", + url=primary_user.record.subscription_url, + ) + builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME)) + builder.adjust(1) + return builder.as_markup() + + if section == PANEL_SECTION_REFERRAL: + if not context.referral_enabled: + builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME)) + builder.adjust(1) + return builder.as_markup() + if context.referral_share_url: + builder.button( + text="📨 Поделиться ссылкой", + url=context.referral_share_url, + ) + builder.button( + text="🎟 Ввести реферальный код", + callback_data=_referral_callback("apply"), + ) + builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME)) + builder.adjust(1) + return builder.as_markup() + + if section == PANEL_SECTION_SUPPORT: + if support_url.strip(): + builder.button(text="Написать в поддержку", url=support_url.strip()) + builder.button( + text="🎫 Создать тикет", + callback_data=_ticket_callback("create"), + ) + builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME)) + builder.adjust(1) + return builder.as_markup() + + if section == PANEL_SECTION_SUPPORT_TICKET: + builder.button( + text="🎫 Создать тикет", + callback_data=_ticket_callback("create"), + ) + builder.button(text="🆘 Поддержка", callback_data=_callback(PANEL_SECTION_SUPPORT)) + builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME)) + builder.adjust(1, 2) + return builder.as_markup() + + if section == PANEL_SECTION_TERMS: + if terms_url: + builder.button(text="📜 Открыть Terms of Use", url=terms_url) + builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME)) + builder.adjust(1) + return builder.as_markup() + + builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME)) + return builder.as_markup() + + +def render_panel_caption(*, section: str, context: PanelContext, brand_name: str) -> str: + if section == PANEL_SECTION_ADMIN: + return _render_admin_caption(context=context, brand_name=brand_name) + if section == PANEL_SECTION_SUBSCRIPTION: + return _render_subscription_caption(context=context) + if section == PANEL_SECTION_REFERRAL: + return _render_referral_caption(context=context) + if section == PANEL_SECTION_SUPPORT: + return _render_support_caption() + if section == PANEL_SECTION_SUPPORT_TICKET: + return _render_support_ticket_caption() + if section == PANEL_SECTION_TERMS: + return _render_terms_caption() + return _render_home_caption(context=context, brand_name=brand_name) + + +def _render_home_caption(*, context: PanelContext, brand_name: str) -> str: + primary_user = _primary_user(context.users) + + sub_lines = [ + f"Статус: {_home_subscription_status(context.users)}", + f"Срок: {_days_left_text(primary_user)}", + f"Трафик: {_traffic_summary_text(primary_user)}", + ] + if primary_user and primary_user.record.subscription_url: + sub_lines.append(f"Ссылка: {html.escape(primary_user.record.subscription_url)}") + + lines = [ + f"Добро пожаловать в {html.escape(brand_name)}", + "", + "Подписка", + "
" + "\n".join(sub_lines) + "
", + ] + + if context.api_error: + lines.extend( + [ + "", + "Remnawave временно недоступен. Показаны данные из локального кэша.", + ] + ) + + if context.is_admin: + lines.extend( + [ + "", + "Для управления ботом используйте кнопку «Админ-панель» ниже.", + ] + ) + + return "\n".join(lines) + + +def _render_admin_caption(*, context: PanelContext, brand_name: str) -> str: + if not context.is_admin: + return "\n".join( + [ + "🛠 Админ-панель", + "", + "
Недостаточно прав для доступа к этому разделу.
", + ] + ) + + return "\n".join( + [ + f"🛠 Админ-панель {html.escape(brand_name)}", + "", + ( + "
" + f"Пользователей в боте: {context.admin_total_telegram_users}\n" + f"Доступов в кэше: {context.admin_total_cached_users}\n" + f"Активных доступов: {context.admin_active_cached_users}\n" + f"Заказов без чека: {context.admin_pending_orders}\n" + f"Чеков на проверке: {context.admin_review_orders}\n" + f"Открытых тикетов: {context.admin_open_tickets}\n" + f"Бонусов в ожидании: {context.admin_pending_referral_bonuses}" + "
" + ), + "", + "Используйте кнопки ниже для поиска пользователей, синхронизации и перехода в рабочие чаты.", + ] + ) + + +def _render_subscription_caption(*, context: PanelContext) -> str: + lines = ["💳 Подписка"] + + if not context.users: + if context.payment_enabled: + lines.extend( + [ + "", + "
У вас пока нет активного доступа.
", + "", + format_payment_plan( + title=context.payment_plan_title, + description=context.payment_plan_description, + price_stars=context.payment_plan_price_stars, + duration_days=context.payment_plan_duration_days, + traffic_limit_bytes=context.payment_plan_traffic_limit_bytes, + ), + "", + ( + "После выбора тарифа бот покажет реквизиты. " + "Затем нужно отправить чек, а администратор подтвердит оплату и выдаст доступ." + ), + ] + ) + else: + lines.extend( + [ + ( + "
" + "У вас пока нет активного доступа.\n\n" + "Если у вас уже есть подписка, откройте поддержку " + "и отправьте short UUID из ссылки подписки." + "
" + ), + "", + "Покупка пока не настроена в .env. Нужны параметры PAYMENT_*.", + ] + ) + return "\n".join(lines) + + lines.append("Ваши доступы") + for index, user in enumerate(context.users[:2], start=1): + squad_text = ", ".join(user.internal_squads[:2]) if user.internal_squads else "не назначены" + if len(squad_text) > 80: + squad_text = f"{squad_text[:77]}..." + lines.append( + ( + f"{index}. {html.escape(user.record.username)} — " + f"{_status_badge(user.record.status)}\n" + "
" + f"Истекает: {format_datetime(user.record.expire_at)}\n" + f"Трафик: {format_bytes(user.record.used_traffic_bytes)} / " + f"{format_traffic_limit(user.record.traffic_limit_bytes)}\n" + f"Short UUID: {html.escape(user.record.short_uuid)}\n" + f"Группы: {html.escape(squad_text)}" + "
" + ) + ) + + if len(context.users) > 2: + lines.append(f"И ещё аккаунтов: {len(context.users) - 2}") + + if context.payment_enabled: + lines.extend( + [ + "", + ( + "" + "Чтобы продлить текущую подписку, используйте кнопку " + "«Продлить подписку». После подтверждения оплаты бот " + "автоматически добавит выбранный срок к вашему доступу." + "" + ), + ] + ) + + if context.api_error: + lines.append("") + lines.append("Последнее обновление пришло из кэша.") + + return "\n".join(lines) + + +def _render_referral_caption(*, context: PanelContext) -> str: + if not context.referral_enabled: + return "\n".join( + [ + "👥 Реферальная система", + "", + "
Реферальная система сейчас отключена администратором.
", + ] + ) + + lines = [ + "👥 Реферальная система", + ( + "
" + f"Ваш реферальный код: {html.escape(context.referral_code or '—')}\n" + f"Приглашено пользователей: {context.referral_count}" + "
" + ), + ] + + if context.referral_bonus_days > 0: + lines.extend( + [ + "", + ( + "
" + f"🎁 Бонус за подтверждённую оплату по вашему коду: " + f"+{context.referral_bonus_days} дней" + "
" + ), + ] + ) + + if context.applied_referral_code: + lines.extend( + [ + "", + "Активированная скидка", + ( + "
" + f"Код: {html.escape(context.applied_referral_code)}\n" + f"Скидка перед оплатой: {max(context.referral_discount_percent, 0)}%" + "
" + ), + ] + ) + else: + lines.extend( + [ + "", + ( + "" + f"Чтобы получить скидку {max(context.referral_discount_percent, 0)}%, " + "код нужно ввести вручную до оплаты." + "" + ), + ] + ) + + if context.referral_link: + lines.extend( + [ + "", + "Ваша ссылка", + f"
{html.escape(context.referral_link)}
", + ] + ) + else: + lines.extend( + [ + "", + "Для генерации ссылки укажите BOT_PUBLIC_USERNAME в .env.", + ] + ) + + if context.referral_recent_names: + lines.extend( + [ + "", + "Последние приглашения", + "
" + "\n".join(html.escape(name) for name in context.referral_recent_names) + "
", + ] + ) + + lines.extend( + [ + "", + "Переход по ссылке сам по себе не активирует скидку. Код нужно ввести вручную перед оплатой.", + ] + ) + + return "\n".join(lines) + + +def _render_terms_caption() -> str: + return "\n".join( + [ + "📜 Правила VPN", + ( + "
" + "1. Используя сервис и бота, вы подтверждаете согласие с правилами платформы и обязуетесь использовать VPN только в законных целях.\n" + "2. VPN предназначен для защиты данных, приватности и безопасного доступа к интернет-ресурсам.\n" + "3. Запрещены передача доступа третьим лицам, спам, взлом, мошенничество, распространение вредоносного ПО, атаки и любой незаконный контент.\n" + "4. Пользователь самостоятельно несёт ответственность за свои действия при использовании сервиса.\n" + "5. Для стабильной работы могут обрабатываться технические данные подключения без передачи третьим лицам, кроме случаев, предусмотренных законодательством.\n" + "6. При нарушении правил доступ может быть ограничен или приостановлен без возврата средств.\n\n" + "⚠️ VPN повышает уровень конфиденциальности, но не гарантирует полной анонимности." + "
" + ), + ] + ) + + +def _render_support_caption() -> str: + return "\n".join( + [ + "🆘 Помощь и сопровождение", + ( + "
" + "Здесь вы можете обратиться в поддержку по вопросам доступа, оплаты, подключения и работы сервиса.\n\n" + "Если нужна помощь, откройте тикет через кнопку ниже." + "
" + ), + ] + ) + + +def _render_support_ticket_caption() -> str: + return "\n".join( + [ + "🎫 Создание тикета", + ( + "
" + "Опишите проблему как можно подробнее: устройство, приложение, текст ошибки и что именно не работает.\n\n" + "После нажатия кнопки ниже бот переведёт вас в режим создания тикета, и следующее сообщение будет отправлено в поддержку." + "
" + ), + ] + ) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..057d9ab --- /dev/null +++ b/app/config.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +from dataclasses import dataclass +from functools import cached_property +from urllib.parse import quote_plus, urlparse + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +@dataclass(frozen=True, slots=True) +class PaymentPlanSettings: + code: str + days: int + amount_rub: int + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + bot_token: str = Field(validation_alias="BOT_TOKEN") + bot_admin_ids_raw: str = Field(default="", validation_alias="BOT_ADMIN_IDS") + bot_moderator_ids_raw: str = Field(default="", validation_alias="BOT_MODERATOR_IDS") + telegram_proxy_url: str = Field(default="", validation_alias="TELEGRAM_PROXY_URL") + bot_brand_name: str = Field(default="OREOL VPN", validation_alias="BOT_BRAND_NAME") + bot_public_username: str = Field(default="", validation_alias="BOT_PUBLIC_USERNAME") + bot_support_url: str = Field(default="", validation_alias="BOT_SUPPORT_URL") + bot_terms_url: str = Field(default="", validation_alias="BOT_TERMS_URL") + bot_support_ticket_link: str = Field(default="", validation_alias="BOT_SUPPORT_TICKET_LINK") + bot_support_ticket_chat_id_raw: str = Field( + default="", + validation_alias="BOT_SUPPORT_TICKET_CHAT_ID", + ) + bot_support_ticket_thread_id: int = Field( + default=0, + validation_alias="BOT_SUPPORT_TICKET_THREAD_ID", + ) + bot_start_image_enabled: bool = Field( + default=True, + validation_alias="BOT_START_IMAGE_ENABLED", + ) + bot_start_image_path: str = Field(default="", validation_alias="BOT_START_IMAGE_PATH") + + remnawave_base_url: str = Field(validation_alias="REMNAWAVE_BASE_URL") + remnawave_api_token: str = Field(default="", validation_alias="REMNAWAVE_API_TOKEN") + remnawave_caddy_api_key: str = Field(default="", validation_alias="REMNAWAVE_CADDY_API_KEY") + remnawave_timeout_seconds: float = Field( + default=20.0, + validation_alias="REMNAWAVE_TIMEOUT_SECONDS", + ) + + db_host: str = Field(default="127.0.0.1", validation_alias="DB_HOST") + db_port: int = Field(default=3306, validation_alias="DB_PORT") + db_name: str = Field(default="telegabot", validation_alias="DB_NAME") + db_user: str = Field(default="telegabot", validation_alias="DB_USER") + db_password: str = Field(default="", validation_alias="DB_PASSWORD") + db_echo: bool = Field(default=False, validation_alias="DB_ECHO") + create_database_on_start: bool = Field( + default=True, + validation_alias="CREATE_DATABASE_ON_START", + ) + create_tables_on_start: bool = Field( + default=True, + validation_alias="CREATE_TABLES_ON_START", + ) + sync_subscription_history: bool = Field( + default=True, + validation_alias="SYNC_SUBSCRIPTION_HISTORY", + ) + sync_batch_size: int = Field(default=100, validation_alias="SYNC_BATCH_SIZE") + log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL") + + payment_plans_raw: str = Field( + default="30:250,180:600,365:1000", + validation_alias="PAYMENT_PLANS", + ) + payment_transfer_text: str = Field( + default="Переведите оплату по указанным реквизитам и отправьте чек в бот.", + validation_alias="PAYMENT_TRANSFER_TEXT", + ) + payment_review_link: str = Field(default="", validation_alias="PAYMENT_REVIEW_LINK") + payment_review_chat_id_raw: str = Field( + default="", + validation_alias="PAYMENT_REVIEW_CHAT_ID", + ) + payment_review_thread_id: int = Field( + default=0, + validation_alias="PAYMENT_REVIEW_THREAD_ID", + ) + referral_discount_percent: int = Field( + default=5, + validation_alias="REFERRAL_DISCOUNT_PERCENT", + ) + referral_bonus_days: int = Field( + default=7, + validation_alias="REFERRAL_BONUS_DAYS", + ) + payment_plan_code: str = Field(default="vpn_30d", validation_alias="PAYMENT_PLAN_CODE") + payment_plan_title: str = Field( + default="OREOL VPN на 30 дней", + validation_alias="PAYMENT_PLAN_TITLE", + ) + payment_plan_description: str = Field( + default="Доступ к VPN на 30 дней", + validation_alias="PAYMENT_PLAN_DESCRIPTION", + ) + payment_plan_price_stars: int = Field(default=500, validation_alias="PAYMENT_PLAN_PRICE_STARS") + payment_plan_duration_days: int = Field( + default=30, + validation_alias="PAYMENT_PLAN_DURATION_DAYS", + ) + payment_plan_traffic_limit_gb: int = Field( + default=0, + validation_alias="PAYMENT_PLAN_TRAFFIC_LIMIT_GB", + ) + payment_plan_traffic_reset_period: str = Field( + default="NO_RESET", + validation_alias="PAYMENT_PLAN_TRAFFIC_RESET_PERIOD", + ) + payment_internal_squad_uuids_raw: str = Field( + default="", + validation_alias="PAYMENT_INTERNAL_SQUAD_UUIDS", + ) + payment_external_squad_uuid: str = Field( + default="", + validation_alias="PAYMENT_EXTERNAL_SQUAD_UUID", + ) + payment_support_text: str = Field( + default="Если оплата прошла, но доступ не выдался, напишите в поддержку.", + validation_alias="PAYMENT_SUPPORT_TEXT", + ) + payment_username_prefix: str = Field( + default="Oreol", + validation_alias="PAYMENT_USERNAME_PREFIX", + ) + payment_user_tag: str = Field(default="BOT", validation_alias="PAYMENT_USER_TAG") + + @cached_property + def bot_admin_ids(self) -> set[int]: + values: set[int] = set() + for raw_part in self.bot_admin_ids_raw.split(","): + part = raw_part.strip() + if part: + values.add(int(part)) + return values + + @cached_property + def bot_moderator_ids(self) -> set[int]: + values: set[int] = set() + for raw_part in self.bot_moderator_ids_raw.split(","): + part = raw_part.strip() + if part: + values.add(int(part)) + return values + + @property + def database_url(self) -> str: + user = quote_plus(self.db_user) + password = quote_plus(self.db_password) + return ( + f"mysql+aiomysql://{user}:{password}" + f"@{self.db_host}:{self.db_port}/{self.db_name}?charset=utf8mb4" + ) + + @property + def database_server_url(self) -> str: + user = quote_plus(self.db_user) + password = quote_plus(self.db_password) + return ( + f"mysql+aiomysql://{user}:{password}" + f"@{self.db_host}:{self.db_port}/?charset=utf8mb4" + ) + + @cached_property + def support_ticket_chat_id(self) -> int | None: + if self.bot_support_ticket_chat_id_raw.strip(): + return int(self.bot_support_ticket_chat_id_raw.strip()) + + parsed_chat_id, _ = self._parse_private_topic_link(self.bot_support_ticket_link) + return parsed_chat_id + + @cached_property + def support_ticket_message_thread_id(self) -> int | None: + if self.bot_support_ticket_thread_id > 0: + return self.bot_support_ticket_thread_id + + _, parsed_thread_id = self._parse_private_topic_link(self.bot_support_ticket_link) + return parsed_thread_id + + @cached_property + def payment_review_chat_id(self) -> int | None: + if self.payment_review_chat_id_raw.strip(): + return int(self.payment_review_chat_id_raw.strip()) + + parsed_chat_id, _ = self._parse_private_topic_link(self.payment_review_link) + return parsed_chat_id + + @cached_property + def payment_review_message_thread_id(self) -> int | None: + if self.payment_review_thread_id > 0: + return self.payment_review_thread_id + + _, parsed_thread_id = self._parse_private_topic_link(self.payment_review_link) + return parsed_thread_id + + @staticmethod + def _parse_private_topic_link(link: str) -> tuple[int | None, int | None]: + cleaned = link.strip() + if not cleaned: + return None, None + + parsed = urlparse(cleaned) + if parsed.netloc not in {"t.me", "telegram.me", "www.t.me", "www.telegram.me"}: + return None, None + + parts = [part for part in parsed.path.split("/") if part] + if len(parts) < 3 or parts[0] != "c" or not parts[1].isdigit(): + return None, None + + chat_id = int(f"-100{parts[1]}") + thread_id = int(parts[2]) if len(parts) >= 4 and parts[2].isdigit() else None + return chat_id, thread_id + + def is_admin(self, telegram_id: int | None) -> bool: + return telegram_id is not None and telegram_id in self.bot_admin_ids + + def is_moderator(self, telegram_id: int | None) -> bool: + return telegram_id is not None and telegram_id in self.bot_moderator_ids + + @property + def bot_public_username_normalized(self) -> str: + return self.bot_public_username.strip().removeprefix("@") + + @cached_property + def payment_internal_squad_uuids(self) -> list[str]: + values: list[str] = [] + for raw_part in self.payment_internal_squad_uuids_raw.split(","): + part = raw_part.strip() + if part: + values.append(part) + return values + + @cached_property + def payment_external_squad_uuid_normalized(self) -> str | None: + value = self.payment_external_squad_uuid.strip() + return value or None + + @cached_property + def payment_username_prefix_normalized(self) -> str: + normalized = "".join( + ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-" + ) + return normalized[:12] or "Oreol" + + @cached_property + def payment_user_tag_normalized(self) -> str | None: + normalized = "".join( + ch for ch in self.payment_user_tag.strip().upper() if ch.isalnum() or ch == "_" + ) + return normalized[:16] or None + + @cached_property + def payment_plans(self) -> list[PaymentPlanSettings]: + values: list[PaymentPlanSettings] = [] + for raw_part in self.payment_plans_raw.split(","): + part = raw_part.strip() + if not part: + continue + + fragments = [fragment.strip() for fragment in part.split(":")] + if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit(): + continue + + days = int(fragments[0]) + amount_rub = int(fragments[1]) + if days <= 0 or amount_rub <= 0: + continue + + values.append( + PaymentPlanSettings( + code=f"{days}d", + days=days, + amount_rub=amount_rub, + ) + ) + + if values: + return sorted(values, key=lambda item: item.days) + + fallback_amount = max(self.payment_plan_price_stars, 0) + fallback_days = max(self.payment_plan_duration_days, 0) + if fallback_amount > 0 and fallback_days > 0: + return [ + PaymentPlanSettings( + code=self.payment_plan_code.strip() or f"{fallback_days}d", + days=fallback_days, + amount_rub=fallback_amount, + ) + ] + + return [] diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..cdce083 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +"""Database package.""" diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..5c1fafe --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,11 @@ +from datetime import datetime, timezone + +from sqlalchemy.orm import DeclarativeBase + + +def utcnow() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class Base(DeclarativeBase): + """Base class for ORM models.""" diff --git a/app/db/models.py b/app/db/models.py new file mode 100644 index 0000000..6a22c5f --- /dev/null +++ b/app/db/models.py @@ -0,0 +1,238 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, utcnow + + +class TelegramUser(Base): + __tablename__ = "telegram_users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True) + username: Mapped[str | None] = mapped_column(String(64), nullable=True) + first_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + last_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + language_code: Mapped[str | None] = mapped_column(String(16), nullable=True) + is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow) + + +class RemnawaveUser(Base): + __tablename__ = "remnawave_users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + owner_telegram_user_id: Mapped[int | None] = mapped_column( + ForeignKey("telegram_users.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + rw_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True) + rw_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True) + short_uuid: Mapped[str] = mapped_column(String(48), nullable=False, unique=True, index=True) + username: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True) + status: Mapped[str] = mapped_column(String(16), nullable=False) + + traffic_limit_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + used_traffic_bytes: Mapped[float] = mapped_column(Float(asdecimal=False), nullable=False, default=0.0) + lifetime_used_traffic_bytes: Mapped[float] = mapped_column(Float(asdecimal=False), nullable=False, default=0.0) + expire_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True) + email: Mapped[str | None] = mapped_column(String(320), nullable=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + tag: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) + hwid_device_limit: Mapped[int | None] = mapped_column(Integer, nullable=True) + external_squad_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True) + + trojan_password: Mapped[str] = mapped_column(String(64), nullable=False) + vless_uuid: Mapped[str] = mapped_column(String(36), nullable=False) + ss_password: Mapped[str] = mapped_column(String(64), nullable=False) + + last_triggered_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + sub_revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + sub_last_user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True) + sub_last_opened_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_traffic_reset_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + subscription_url: Mapped[str] = mapped_column(Text, nullable=False) + + online_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + first_connected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_connected_node_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True) + + remote_created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + remote_updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + + +class InternalSquad(Base): + __tablename__ = "internal_squads" + + uuid: Mapped[str] = mapped_column(String(36), primary_key=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + + +class RemnawaveUserInternalSquad(Base): + __tablename__ = "remnawave_user_internal_squads" + + user_id: Mapped[int] = mapped_column( + ForeignKey("remnawave_users.id", ondelete="CASCADE"), + primary_key=True, + ) + squad_uuid: Mapped[str] = mapped_column( + ForeignKey("internal_squads.uuid", ondelete="CASCADE"), + primary_key=True, + ) + + +class SubscriptionRequestLog(Base): + __tablename__ = "subscription_request_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + remote_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True) + user_uuid: Mapped[str] = mapped_column( + ForeignKey("remnawave_users.rw_uuid", ondelete="CASCADE"), + nullable=False, + index=True, + ) + request_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + request_ip: Mapped[str | None] = mapped_column(String(45), nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) + synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + + +class SupportTicket(Base): + __tablename__ = "support_tickets" + __table_args__ = ( + Index( + "ix_support_tickets_chat_message", + "support_chat_id", + "support_message_id", + unique=True, + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + public_id: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, index=True) + + telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + username: Mapped[str | None] = mapped_column(String(64), nullable=True) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + user_message: Mapped[str] = mapped_column(Text, nullable=False) + + support_chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + support_thread_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + support_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + + status: Mapped[str] = mapped_column(String(16), nullable=False, default="OPEN") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow) + + +class ReferralInvite(Base): + __tablename__ = "referral_invites" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + inviter_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + invited_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True) + invited_username: Mapped[str | None] = mapped_column(String(64), nullable=True) + invited_display_name: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + + +class ReferralCode(Base): + __tablename__ = "referral_codes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True) + code: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow) + + +class ReferralBonus(Base): + __tablename__ = "referral_bonuses" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + inviter_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + invited_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + source_order_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True) + bonus_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="PENDING", index=True) + applied_to_user_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + applied_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow) + + +class BotSetting(Base): + __tablename__ = "bot_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) + value: Mapped[str] = mapped_column(Text, nullable=False, default="") + updated_by_telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow) + + +class SubscriptionNotification(Base): + __tablename__ = "subscription_notifications" + __table_args__ = ( + Index( + "ix_sub_notif_unique", + "remnawave_user_id", + "notification_type", + "expire_at_snapshot", + unique=True, + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + remnawave_user_id: Mapped[int] = mapped_column( + ForeignKey("remnawave_users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + notification_type: Mapped[str] = mapped_column(String(32), nullable=False) + expire_at_snapshot: Mapped[datetime] = mapped_column(DateTime, nullable=False) + sent_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + + +class PaymentOrder(Base): + __tablename__ = "payment_orders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + order_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True, index=True) + telegram_user_id: Mapped[int] = mapped_column( + ForeignKey("telegram_users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + plan_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + plan_title: Mapped[str] = mapped_column(String(255), nullable=False) + plan_duration_days: Mapped[int] = mapped_column(Integer, nullable=False) + traffic_limit_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + traffic_limit_strategy: Mapped[str] = mapped_column(String(32), nullable=False, default="NO_RESET") + amount_stars: Mapped[int] = mapped_column(Integer, nullable=False) + currency: Mapped[str] = mapped_column(String(8), nullable=False, default="XTR") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="PENDING", index=True) + invoice_payload: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True) + provision_username: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + remnawave_user_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + subscription_url: Mapped[str | None] = mapped_column(Text, nullable=True) + telegram_payment_charge_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True) + provider_payment_charge_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + fulfilled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow) diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..1959c29 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,152 @@ +import logging + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import ( + AsyncConnection, + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from app.db.base import Base +from app.db import models as _models # noqa: F401 + +logger = logging.getLogger(__name__) + +PAYMENT_ORDERS_TABLE_NAME = "payment_orders" +PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN = "provision_username" +PAYMENT_ORDERS_PROVISION_USERNAME_INDEX = "ix_payment_orders_provision_username" + + +def create_engine_and_session_factory( + database_url: str, + *, + echo: bool = False, +) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: + engine = create_async_engine( + database_url, + echo=echo, + pool_pre_ping=True, + pool_recycle=3600, + ) + session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + return engine, session_factory + + +def _quote_mysql_identifier(identifier: str) -> str: + return f"`{identifier.replace('`', '``')}`" + + +async def _get_mysql_single_column_indexes( + connection: AsyncConnection, + *, + table_name: str, + column_name: str, + non_unique: int, +) -> list[str]: + result = await connection.execute( + text( + """ + SELECT index_name + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = :table_name + GROUP BY index_name, non_unique + HAVING non_unique = :non_unique + AND COUNT(*) = 1 + AND MAX(CASE WHEN column_name = :column_name THEN 1 ELSE 0 END) = 1 + """ + ), + { + "table_name": table_name, + "column_name": column_name, + "non_unique": non_unique, + }, + ) + return [row[0] for row in result] + + +async def _normalize_payment_order_provision_username_index( + connection: AsyncConnection, +) -> None: + if connection.dialect.name != "mysql": + return + + unique_index_names = await _get_mysql_single_column_indexes( + connection, + table_name=PAYMENT_ORDERS_TABLE_NAME, + column_name=PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN, + non_unique=0, + ) + + quoted_table_name = _quote_mysql_identifier(PAYMENT_ORDERS_TABLE_NAME) + for index_name in unique_index_names: + logger.info( + "Dropping stale UNIQUE index %s on %s.%s", + index_name, + PAYMENT_ORDERS_TABLE_NAME, + PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN, + ) + await connection.execute( + text( + "DROP INDEX " + f"{_quote_mysql_identifier(index_name)} " + f"ON {quoted_table_name}" + ) + ) + + non_unique_index_names = await _get_mysql_single_column_indexes( + connection, + table_name=PAYMENT_ORDERS_TABLE_NAME, + column_name=PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN, + non_unique=1, + ) + if non_unique_index_names: + return + + logger.info( + "Creating non-unique index %s on %s.%s", + PAYMENT_ORDERS_PROVISION_USERNAME_INDEX, + PAYMENT_ORDERS_TABLE_NAME, + PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN, + ) + await connection.execute( + text( + "CREATE INDEX " + f"{_quote_mysql_identifier(PAYMENT_ORDERS_PROVISION_USERNAME_INDEX)} " + f"ON {quoted_table_name} " + f"({_quote_mysql_identifier(PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN)})" + ) + ) + + +async def ensure_database_exists( + server_url: str, + database_name: str, + *, + echo: bool = False, +) -> None: + engine = create_async_engine( + server_url, + echo=echo, + pool_pre_ping=True, + pool_recycle=3600, + ) + try: + async with engine.begin() as connection: + await connection.execute( + text( + "CREATE DATABASE IF NOT EXISTS " + f"{_quote_mysql_identifier(database_name)} " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + ) + finally: + await engine.dispose() + + +async def init_db(engine: AsyncEngine) -> None: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await _normalize_payment_order_provision_username_index(connection) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..aa14449 --- /dev/null +++ b/app/main.py @@ -0,0 +1,135 @@ +import asyncio +import logging +from pathlib import Path + +from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties +from aiogram.client.session.aiohttp import AiohttpSession +from aiogram.enums import ParseMode +from aiogram.exceptions import TelegramNetworkError +from aiogram.types import BotCommand + +from app.bot.handlers.main import build_router +from app.config import Settings +from app.db.session import create_engine_and_session_factory, ensure_database_exists, init_db +from app.services.bot_config_service import BotConfigService +from app.services.notification_service import SubscriptionNotificationService +from app.services.payment_service import PaymentService +from app.services.remnawave_client import RemnawaveApiClient +from app.services.support_ticket_service import SupportTicketService +from app.services.sync_service import SyncService + + +async def main() -> None: + settings = Settings() + + logging.basicConfig( + level=getattr(logging, settings.log_level.upper(), logging.INFO), + format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", + ) + + logger = logging.getLogger(__name__) + engine = None + remnawave_client = None + bot = None + + try: + if settings.create_database_on_start: + logger.info( + "Ensuring MariaDB database '%s' exists on %s:%s", + settings.db_name, + settings.db_host, + settings.db_port, + ) + await ensure_database_exists( + settings.database_server_url, + settings.db_name, + echo=settings.db_echo, + ) + + engine, session_factory = create_engine_and_session_factory( + settings.database_url, + echo=settings.db_echo, + ) + + if settings.create_tables_on_start: + await init_db(engine) + + remnawave_client = RemnawaveApiClient( + base_url=settings.remnawave_base_url, + api_token=settings.remnawave_api_token, + caddy_api_key=settings.remnawave_caddy_api_key, + timeout_seconds=settings.remnawave_timeout_seconds, + ) + sync_service = SyncService( + settings=settings, + session_factory=session_factory, + remnawave_client=remnawave_client, + ) + bot_config_service = BotConfigService( + settings=settings, + session_factory=session_factory, + ) + support_ticket_service = SupportTicketService(session_factory=session_factory) + payment_service = PaymentService( + settings=settings, + session_factory=session_factory, + remnawave_client=remnawave_client, + sync_service=sync_service, + config_service=bot_config_service, + ) + + bot_session = AiohttpSession(proxy=settings.telegram_proxy_url or None) + bot = Bot( + token=settings.bot_token, + session=bot_session, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + dispatcher = Dispatcher() + dispatcher.include_router( + build_router( + settings=settings, + sync_service=sync_service, + support_ticket_service=support_ticket_service, + payment_service=payment_service, + bot_config_service=bot_config_service, + ) + ) + + await bot.set_my_commands( + [ + BotCommand(command="start", description="Открыть главное меню"), + ] + ) + + project_root = Path(__file__).resolve().parent.parent + notification_service = SubscriptionNotificationService( + session_factory=session_factory, + sync_service=sync_service, + config_service=bot_config_service, + project_root=project_root, + ) + notification_task = asyncio.create_task( + notification_service.run_periodic(bot), + ) + + try: + await dispatcher.start_polling(bot) + finally: + notification_task.cancel() + try: + await notification_task + except asyncio.CancelledError: + pass + except TelegramNetworkError: + logger.exception( + "Cannot reach Telegram API. Check outbound HTTPS access to api.telegram.org:443, DNS, firewall, VPN, or proxy." + ) + raise + finally: + if remnawave_client is not None: + await remnawave_client.close() + if bot is not None: + await bot.session.close() + if engine is not None: + await engine.dispose() diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..f391682 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic schemas.""" diff --git a/app/schemas/remnawave.py b/app/schemas/remnawave.py new file mode 100644 index 0000000..a3bc816 --- /dev/null +++ b/app/schemas/remnawave.py @@ -0,0 +1,75 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class RemnawaveBaseModel(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + +class ActiveInternalSquad(RemnawaveBaseModel): + uuid: UUID + name: str + + +class UserTraffic(RemnawaveBaseModel): + used_traffic_bytes: float = Field(alias="usedTrafficBytes") + lifetime_used_traffic_bytes: float = Field(alias="lifetimeUsedTrafficBytes") + online_at: datetime | None = Field(default=None, alias="onlineAt") + first_connected_at: datetime | None = Field(default=None, alias="firstConnectedAt") + last_connected_node_uuid: UUID | None = Field(default=None, alias="lastConnectedNodeUuid") + + +class RemnawaveUser(RemnawaveBaseModel): + uuid: UUID + id: int + short_uuid: str = Field(alias="shortUuid") + username: str + status: str + traffic_limit_bytes: int = Field(alias="trafficLimitBytes") + expire_at: datetime = Field(alias="expireAt") + telegram_id: int | None = Field(default=None, alias="telegramId") + email: str | None = None + description: str | None = None + tag: str | None = None + hwid_device_limit: int | None = Field(default=None, alias="hwidDeviceLimit") + external_squad_uuid: UUID | None = Field(default=None, alias="externalSquadUuid") + trojan_password: str = Field(alias="trojanPassword") + vless_uuid: UUID = Field(alias="vlessUuid") + ss_password: str = Field(alias="ssPassword") + last_triggered_threshold: int = Field(alias="lastTriggeredThreshold") + sub_revoked_at: datetime | None = Field(default=None, alias="subRevokedAt") + sub_last_user_agent: str | None = Field(default=None, alias="subLastUserAgent") + sub_last_opened_at: datetime | None = Field(default=None, alias="subLastOpenedAt") + last_traffic_reset_at: datetime | None = Field(default=None, alias="lastTrafficResetAt") + created_at: datetime = Field(alias="createdAt") + updated_at: datetime = Field(alias="updatedAt") + subscription_url: str = Field(alias="subscriptionUrl") + active_internal_squads: list[ActiveInternalSquad] = Field(alias="activeInternalSquads") + user_traffic: UserTraffic = Field(alias="userTraffic") + + +class PaginatedUsers(RemnawaveBaseModel): + users: list[RemnawaveUser] + total: int + + +class ResolvedUser(RemnawaveBaseModel): + uuid: UUID + username: str + id: int + short_uuid: str = Field(alias="shortUuid") + + +class SubscriptionRequestRecord(RemnawaveBaseModel): + id: int + user_uuid: UUID = Field(alias="userUuid") + request_at: datetime = Field(alias="requestAt") + request_ip: str | None = Field(default=None, alias="requestIp") + user_agent: str | None = Field(default=None, alias="userAgent") + + +class SubscriptionRequestHistory(RemnawaveBaseModel): + total: int + records: list[SubscriptionRequestRecord] diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..c7775ec --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Services package.""" diff --git a/app/services/bot_config_service.py b/app/services/bot_config_service.py new file mode 100644 index 0000000..0c1f4d5 --- /dev/null +++ b/app/services/bot_config_service.py @@ -0,0 +1,744 @@ +from __future__ import annotations + +from dataclasses import dataclass +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.config import PaymentPlanSettings, Settings +from app.db.base import utcnow +from app.db.models import BotSetting + + +@dataclass(frozen=True, slots=True) +class BotConfigFieldSpec: + key: str + label: str + section: str + prompt: str + description: str + placeholder: str = "" + is_secret_like: bool = False + + +@dataclass(slots=True) +class BotConfigSnapshot: + bot_brand_name: str + bot_public_username: str + bot_support_url: str + bot_terms_url: str + bot_support_ticket_link: str + payment_review_link: str + bot_start_image_enabled: bool + bot_start_image_path: str + referral_enabled: bool + referral_discount_percent: int + referral_bonus_days: int + payment_plans_raw: str + payment_transfer_text: str + payment_support_text: str + payment_plan_traffic_limit_gb: int + payment_plan_traffic_reset_period: str + payment_internal_squad_uuids_raw: str + payment_external_squad_uuid: str + payment_username_prefix: str + payment_user_tag: str + notification_enabled: bool = True + notification_days_before: str = "1,3" + notification_check_interval_hours: int = 6 + fallback_support_ticket_chat_id: int | None = None + fallback_support_ticket_thread_id: int | None = None + fallback_payment_review_chat_id: int | None = None + fallback_payment_review_thread_id: int | None = None + + @property + def bot_public_username_normalized(self) -> str: + return self.bot_public_username.strip().removeprefix("@") + + @property + def payment_external_squad_uuid_normalized(self) -> str | None: + value = self.payment_external_squad_uuid.strip() + return value or None + + @property + def payment_username_prefix_normalized(self) -> str: + normalized = "".join( + ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-" + ) + return normalized[:12] or "Oreol" + + @property + def payment_user_tag_normalized(self) -> str | None: + normalized = "".join( + ch for ch in self.payment_user_tag.strip().upper() if ch.isalnum() or ch == "_" + ) + return normalized[:16] or None + + @property + def payment_internal_squad_uuids(self) -> list[str]: + values: list[str] = [] + for raw_part in self.payment_internal_squad_uuids_raw.split(","): + part = raw_part.strip() + if part: + values.append(part) + return values + + @property + def notification_days_list(self) -> list[int]: + values: list[int] = [] + for raw_part in self.notification_days_before.split(","): + part = raw_part.strip() + if part.isdigit(): + day = int(part) + if day > 0: + values.append(day) + return sorted(set(values), reverse=True) if values else [1, 3] + + @property + def payment_plans(self) -> list[PaymentPlanSettings]: + values: list[PaymentPlanSettings] = [] + for raw_part in self.payment_plans_raw.split(","): + part = raw_part.strip() + if not part: + continue + + fragments = [fragment.strip() for fragment in part.split(":")] + if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit(): + continue + + days = int(fragments[0]) + amount_rub = int(fragments[1]) + if days <= 0 or amount_rub <= 0: + continue + + values.append( + PaymentPlanSettings( + code=f"{days}d", + days=days, + amount_rub=amount_rub, + ) + ) + + return sorted(values, key=lambda item: item.days) + + @staticmethod + def _compact_text(raw_value: str, *, max_length: int = 80) -> str: + normalized = " ".join(raw_value.split()).strip() + if not normalized: + return "-" + if len(normalized) <= max_length: + return normalized + return f"{normalized[: max_length - 3]}..." + + @property + def support_ticket_chat_id(self) -> int | None: + parsed_chat_id, _ = Settings._parse_private_topic_link(self.bot_support_ticket_link) + return parsed_chat_id if parsed_chat_id is not None else self.fallback_support_ticket_chat_id + + @property + def support_ticket_message_thread_id(self) -> int | None: + _, parsed_thread_id = Settings._parse_private_topic_link(self.bot_support_ticket_link) + return ( + parsed_thread_id + if parsed_thread_id is not None + else self.fallback_support_ticket_thread_id + ) + + @property + def payment_review_chat_id(self) -> int | None: + parsed_chat_id, _ = Settings._parse_private_topic_link(self.payment_review_link) + return parsed_chat_id if parsed_chat_id is not None else self.fallback_payment_review_chat_id + + @property + def payment_review_message_thread_id(self) -> int | None: + _, parsed_thread_id = Settings._parse_private_topic_link(self.payment_review_link) + return ( + parsed_thread_id + if parsed_thread_id is not None + else self.fallback_payment_review_thread_id + ) + + +class StaticBotConfigService: + def __init__(self, settings: Settings) -> None: + self._settings = settings + + async def get_snapshot(self) -> BotConfigSnapshot: + return BotConfigSnapshot( + bot_brand_name=self._settings.bot_brand_name, + bot_public_username=self._settings.bot_public_username, + bot_support_url=self._settings.bot_support_url, + bot_terms_url=self._settings.bot_terms_url, + bot_support_ticket_link=self._settings.bot_support_ticket_link, + payment_review_link=self._settings.payment_review_link, + bot_start_image_enabled=self._settings.bot_start_image_enabled, + bot_start_image_path=self._settings.bot_start_image_path, + referral_enabled=True, + referral_discount_percent=self._settings.referral_discount_percent, + referral_bonus_days=self._settings.referral_bonus_days, + payment_plans_raw=self._settings.payment_plans_raw, + payment_transfer_text=self._settings.payment_transfer_text, + payment_support_text=self._settings.payment_support_text, + payment_plan_traffic_limit_gb=self._settings.payment_plan_traffic_limit_gb, + payment_plan_traffic_reset_period=self._settings.payment_plan_traffic_reset_period, + notification_enabled=True, + notification_days_before="1,3", + notification_check_interval_hours=6, + payment_internal_squad_uuids_raw=self._settings.payment_internal_squad_uuids_raw, + payment_external_squad_uuid=self._settings.payment_external_squad_uuid, + payment_username_prefix=self._settings.payment_username_prefix, + payment_user_tag=self._settings.payment_user_tag, + fallback_support_ticket_chat_id=self._settings.support_ticket_chat_id, + fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id, + fallback_payment_review_chat_id=self._settings.payment_review_chat_id, + fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id, + ) + + +class BotConfigService(StaticBotConfigService): + FIELD_SPECS: dict[str, BotConfigFieldSpec] = { + "bot_brand_name": BotConfigFieldSpec( + key="bot_brand_name", + label="Название бренда", + section="brand", + prompt="Введите новое название бренда.", + description="Используется в главной панели и пользовательских сообщениях.", + placeholder="OREOL VPN", + ), + "bot_public_username": BotConfigFieldSpec( + key="bot_public_username", + label="Username бота", + section="brand", + prompt="Введите username бота без @. Для очистки отправьте `-`.", + description="Нужен для генерации реферальных ссылок.", + placeholder="oreol_vpn_bot", + ), + "bot_start_image_enabled": BotConfigFieldSpec( + key="bot_start_image_enabled", + label="Стартовая картинка", + section="brand", + prompt="Введите `on` или `off`.", + description="Включает или выключает бренд-картинку в панели и выдаче доступа.", + placeholder="on", + ), + "bot_start_image_path": BotConfigFieldSpec( + key="bot_start_image_path", + label="Путь к картинке", + section="brand", + prompt="Введите путь к изображению. Для очистки отправьте `-`.", + description="Можно указать относительный путь от корня проекта.", + placeholder="assets/main.png", + ), + "payment_plans_raw": BotConfigFieldSpec( + key="payment_plans_raw", + label="Тарифы и цены", + section="pricing", + prompt="Введите тарифы в формате `30:250,180:600,365:1000`.", + description="Каждая пара — это `дни:цена_в_рублях`.", + placeholder="30:250,180:600,365:1000", + ), + "payment_plan_traffic_limit_gb": BotConfigFieldSpec( + key="payment_plan_traffic_limit_gb", + label="Лимит трафика, GB", + section="pricing", + prompt="Введите лимит трафика в гигабайтах. `0` = безлимит.", + description="Применяется к тарифам, создаваемым ботом.", + placeholder="0", + ), + "payment_plan_traffic_reset_period": BotConfigFieldSpec( + key="payment_plan_traffic_reset_period", + label="Стратегия сброса трафика", + section="pricing", + prompt="Введите стратегию, например `NO_RESET`.", + description="Значение передаётся в Remnawave.", + placeholder="NO_RESET", + ), + "referral_enabled": BotConfigFieldSpec( + key="referral_enabled", + label="Реферальная система", + section="referral", + prompt="Введите `on` или `off`.", + description="Включает или выключает ввод рефкода и начисление реферальных бонусов.", + placeholder="on", + ), + "referral_discount_percent": BotConfigFieldSpec( + key="referral_discount_percent", + label="Скидка по рефкоду, %", + section="referral", + prompt="Введите размер скидки в процентах от 0 до 100.", + description="Применяется к покупателю перед оплатой.", + placeholder="5", + ), + "referral_bonus_days": BotConfigFieldSpec( + key="referral_bonus_days", + label="Бонус рефереру, дней", + section="referral", + prompt="Введите количество бонусных дней. `0` отключает бонус.", + description="Начисляется после подтверждённой оплаты по рефкоду.", + placeholder="7", + ), + "bot_support_url": BotConfigFieldSpec( + key="bot_support_url", + label="Ссылка на поддержку", + section="links", + prompt="Введите URL поддержки. Для очистки отправьте `-`.", + description="Кнопка быстрой связи с поддержкой в пользовательской панели.", + placeholder="https://t.me/your_support_account", + ), + "bot_terms_url": BotConfigFieldSpec( + key="bot_terms_url", + label="Ссылка на Terms", + section="links", + prompt="Введите URL правил использования. Для очистки отправьте `-`.", + description="Открывается из раздела условий использования.", + placeholder="https://example.com/terms", + ), + "bot_support_ticket_link": BotConfigFieldSpec( + key="bot_support_ticket_link", + label="Ссылка на чат тикетов", + section="links", + prompt="Введите приватную ссылку на чат/топик тикетов. Для очистки отправьте `-`.", + description="Формат: `https://t.me/c///`.", + placeholder="https://t.me/c/1234567890/2/3", + ), + "payment_review_link": BotConfigFieldSpec( + key="payment_review_link", + label="Ссылка на очередь оплат", + section="links", + prompt="Введите приватную ссылку на чат/топик проверки оплат. Для очистки отправьте `-`.", + description="Формат: `https://t.me/c///`.", + placeholder="https://t.me/c/1234567890/56/57", + ), + "payment_transfer_text": BotConfigFieldSpec( + key="payment_transfer_text", + label="Инструкция по оплате", + section="texts", + prompt="Введите текст с реквизитами или инструкцией перевода.", + description="Показывается пользователю после выбора тарифа.", + placeholder="Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN", + ), + "payment_support_text": BotConfigFieldSpec( + key="payment_support_text", + label="Текст поддержки по оплате", + section="texts", + prompt="Введите текст, который бот покажет при проблемах с оплатой.", + description="Используется при отклонении оплаты и ошибках выдачи доступа.", + placeholder="Если оплата прошла, но доступ не выдался, напишите в поддержку.", + ), + "payment_internal_squad_uuids_raw": BotConfigFieldSpec( + key="payment_internal_squad_uuids_raw", + label="UUID внутренних групп", + section="provisioning", + prompt="Введите UUID внутренних групп через запятую.", + description="Эти группы назначаются пользователю в Remnawave.", + placeholder="11111111-1111-1111-1111-111111111111", + ), + "payment_external_squad_uuid": BotConfigFieldSpec( + key="payment_external_squad_uuid", + label="UUID внешней группы", + section="provisioning", + prompt="Введите UUID внешней группы. Для очистки отправьте `-`.", + description="Необязательный внешний squad для новых пользователей.", + placeholder="11111111-1111-1111-1111-111111111111", + ), + "payment_username_prefix": BotConfigFieldSpec( + key="payment_username_prefix", + label="Префикс логина", + section="provisioning", + prompt="Введите префикс логина для новых пользователей Remnawave.", + description="Итоговый логин строится как `--`.", + placeholder="Oreol", + ), + "payment_user_tag": BotConfigFieldSpec( + key="payment_user_tag", + label="Tag пользователей", + section="provisioning", + prompt="Введите tag для создаваемых пользователей. Для очистки отправьте `-`.", + description="Опциональный tag в Remnawave.", + placeholder="BOT", + ), + "notification_enabled": BotConfigFieldSpec( + key="notification_enabled", + label="Уведомления об истечении", + section="notifications", + prompt="Введите `on` или `off`.", + description="Включает или выключает уведомления о скором истечении подписки.", + placeholder="on", + ), + "notification_days_before": BotConfigFieldSpec( + key="notification_days_before", + label="Дни до истечения", + section="notifications", + prompt="Введите дни через запятую, например `1,3`.", + description="За сколько дней до истечения отправлять уведомления.", + placeholder="1,3", + ), + "notification_check_interval_hours": BotConfigFieldSpec( + key="notification_check_interval_hours", + label="Интервал проверки, ч", + section="notifications", + prompt="Введите интервал проверки в часах.", + description="Как часто бот проверяет подписки на истечение.", + placeholder="6", + ), + } + + SECTION_LABELS: dict[str, str] = { + "brand": "Бренд", + "pricing": "Тарифы", + "referral": "Рефералка", + "links": "Ссылки", + "texts": "Тексты", + "provisioning": "Provisioning", + "notifications": "Уведомления", + } + + def __init__( + self, + *, + settings: Settings, + session_factory: async_sessionmaker[AsyncSession], + ) -> None: + super().__init__(settings) + self._session_factory = session_factory + + async def get_snapshot(self) -> BotConfigSnapshot: + overrides = await self._get_overrides() + + return BotConfigSnapshot( + bot_brand_name=str(overrides.get("bot_brand_name", self._settings.bot_brand_name)), + bot_public_username=str(overrides.get("bot_public_username", self._settings.bot_public_username)), + bot_support_url=str(overrides.get("bot_support_url", self._settings.bot_support_url)), + bot_terms_url=str(overrides.get("bot_terms_url", self._settings.bot_terms_url)), + bot_support_ticket_link=str(overrides.get("bot_support_ticket_link", self._settings.bot_support_ticket_link)), + payment_review_link=str(overrides.get("payment_review_link", self._settings.payment_review_link)), + bot_start_image_enabled=self._parse_bool( + overrides.get("bot_start_image_enabled"), + default=self._settings.bot_start_image_enabled, + ), + bot_start_image_path=str(overrides.get("bot_start_image_path", self._settings.bot_start_image_path)), + referral_enabled=self._parse_bool( + overrides.get("referral_enabled"), + default=True, + ), + referral_discount_percent=self._parse_int( + overrides.get("referral_discount_percent"), + default=self._settings.referral_discount_percent, + ), + referral_bonus_days=self._parse_int( + overrides.get("referral_bonus_days"), + default=self._settings.referral_bonus_days, + ), + payment_plans_raw=str(overrides.get("payment_plans_raw", self._settings.payment_plans_raw)), + payment_transfer_text=str(overrides.get("payment_transfer_text", self._settings.payment_transfer_text)), + payment_support_text=str(overrides.get("payment_support_text", self._settings.payment_support_text)), + payment_plan_traffic_limit_gb=self._parse_int( + overrides.get("payment_plan_traffic_limit_gb"), + default=self._settings.payment_plan_traffic_limit_gb, + ), + payment_plan_traffic_reset_period=str( + overrides.get( + "payment_plan_traffic_reset_period", + self._settings.payment_plan_traffic_reset_period, + ) + ), + payment_internal_squad_uuids_raw=str( + overrides.get( + "payment_internal_squad_uuids_raw", + self._settings.payment_internal_squad_uuids_raw, + ) + ), + payment_external_squad_uuid=str( + overrides.get( + "payment_external_squad_uuid", + self._settings.payment_external_squad_uuid, + ) + ), + payment_username_prefix=str( + overrides.get("payment_username_prefix", self._settings.payment_username_prefix) + ), + payment_user_tag=str(overrides.get("payment_user_tag", self._settings.payment_user_tag)), + notification_enabled=self._parse_bool( + overrides.get("notification_enabled"), + default=True, + ), + notification_days_before=str(overrides.get("notification_days_before", "1,3")), + notification_check_interval_hours=self._parse_int( + overrides.get("notification_check_interval_hours"), + default=6, + ), + fallback_support_ticket_chat_id=self._settings.support_ticket_chat_id, + fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id, + fallback_payment_review_chat_id=self._settings.payment_review_chat_id, + fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id, + ) + + async def update_setting( + self, + *, + key: str, + raw_value: str, + updated_by_telegram_id: int, + ) -> str: + spec = self.FIELD_SPECS.get(key) + if spec is None: + raise ValueError("Неизвестная настройка.") + + normalized_value = self._normalize_value(key, raw_value) + + async with self._session_factory() as session: + record = await session.scalar( + select(BotSetting).where(BotSetting.key == key) + ) + if record is None: + record = BotSetting(key=key) + session.add(record) + + record.value = normalized_value + record.updated_by_telegram_id = updated_by_telegram_id + record.updated_at = utcnow() + await session.commit() + + return normalized_value + + @classmethod + def get_spec(cls, key: str) -> BotConfigFieldSpec | None: + return cls.FIELD_SPECS.get(key) + + @classmethod + def get_specs_for_section(cls, section: str) -> list[BotConfigFieldSpec]: + return [spec for spec in cls.FIELD_SPECS.values() if spec.section == section] + + @classmethod + def get_sections(cls) -> list[tuple[str, str]]: + return list(cls.SECTION_LABELS.items()) + + @classmethod + def get_section_label(cls, section: str) -> str: + return cls.SECTION_LABELS.get(section, section) + + @classmethod + def format_value(cls, *, snapshot: BotConfigSnapshot, key: str) -> str: + value = getattr(snapshot, key, "") + + if key in {"bot_start_image_enabled", "referral_enabled", "notification_enabled"}: + return "on" if bool(value) else "off" + + if key == "bot_public_username": + normalized = snapshot.bot_public_username_normalized + return f"@{normalized}" if normalized else "-" + + if key in { + "bot_support_url", + "bot_terms_url", + "bot_support_ticket_link", + "payment_review_link", + "bot_start_image_path", + "payment_external_squad_uuid", + "payment_user_tag", + "payment_username_prefix", + "payment_internal_squad_uuids_raw", + "payment_plans_raw", + "payment_plan_traffic_reset_period", + "bot_brand_name", + "notification_days_before", + }: + return cls._compact_text(str(value)) + + if key == "payment_plan_traffic_limit_gb": + amount = int(value or 0) + return "0 GB (unlimited)" if amount <= 0 else f"{amount} GB" + + if key == "referral_discount_percent": + return f"{max(int(value or 0), 0)}%" + + if key == "referral_bonus_days": + return f"{max(int(value or 0), 0)} days" + + if key == "notification_check_interval_hours": + return f"{max(int(value or 6), 1)} ч" + + if key in {"payment_transfer_text", "payment_support_text"}: + return cls._compact_text(str(value), max_length=120) + + return cls._compact_text(str(value)) + + @staticmethod + def _compact_text(raw_value: str, *, max_length: int = 80) -> str: + normalized = " ".join(raw_value.split()).strip() + if not normalized: + return "-" + if len(normalized) <= max_length: + return normalized + return f"{normalized[: max_length - 3]}..." + + async def _get_overrides(self) -> dict[str, str]: + async with self._session_factory() as session: + rows = await session.execute(select(BotSetting)) + return { + row.key: row.value + for row in rows.scalars().all() + } + + def _normalize_value(self, key: str, raw_value: str) -> str: + value = raw_value.strip() + if key in { + "bot_support_url", + "bot_terms_url", + "bot_support_ticket_link", + "payment_review_link", + "bot_start_image_path", + "payment_external_squad_uuid", + "payment_user_tag", + "bot_public_username", + } and value == "-": + return "" + + if key == "bot_brand_name": + if not value: + raise ValueError("Название бренда не может быть пустым.") + return value[:80] + + if key == "bot_public_username": + return value.removeprefix("@") + + if key == "bot_start_image_enabled": + return "true" if self._parse_bool_token(value) else "false" + + if key == "bot_start_image_path": + return value + + if key == "payment_plans_raw": + plans = self._parse_payment_plans(value) + return ",".join(f"{plan.days}:{plan.amount_rub}" for plan in plans) + + if key == "payment_plan_traffic_limit_gb": + amount = self._parse_non_negative_int(value, "Лимит трафика") + return str(amount) + + if key == "payment_plan_traffic_reset_period": + if not value: + raise ValueError("Стратегия сброса трафика не может быть пустой.") + return value.upper() + + if key == "referral_enabled": + return "true" if self._parse_bool_token(value) else "false" + + if key == "referral_discount_percent": + amount = self._parse_non_negative_int(value, "Размер скидки") + if amount > 100: + raise ValueError("Размер скидки должен быть в диапазоне от 0 до 100.") + return str(amount) + + if key == "referral_bonus_days": + amount = self._parse_non_negative_int(value, "Размер бонуса") + return str(amount) + + if key in { + "bot_support_url", + "bot_terms_url", + "bot_support_ticket_link", + "payment_review_link", + }: + return value + + if key in {"payment_transfer_text", "payment_support_text"}: + if not value: + raise ValueError("Текст не может быть пустым.") + return value[:4000] + + if key == "payment_internal_squad_uuids_raw": + normalized_parts = [part.strip() for part in value.split(",") if part.strip()] + if not normalized_parts: + raise ValueError("Нужно указать хотя бы один UUID внутренней группы.") + return ",".join(normalized_parts) + + if key == "payment_external_squad_uuid": + return value + + if key == "payment_username_prefix": + if not value: + raise ValueError("Префикс логина не может быть пустым.") + return value[:32] + + if key == "payment_user_tag": + return value + + if key == "notification_enabled": + return "true" if self._parse_bool_token(value) else "false" + + if key == "notification_days_before": + parts = [p.strip() for p in value.split(",") if p.strip().isdigit() and int(p.strip()) > 0] + if not parts: + raise ValueError("Нужно указать хотя бы один день, например `1,3`.") + return ",".join(parts) + + if key == "notification_check_interval_hours": + amount = self._parse_non_negative_int(value, "Интервал проверки") + if amount < 1: + raise ValueError("Интервал должен быть не менее 1 часа.") + return str(amount) + + raise ValueError("Настройка пока не поддерживается.") + + @staticmethod + def _parse_int(raw_value: object | None, *, default: int) -> int: + try: + return int(str(raw_value).strip()) + except (TypeError, ValueError): + return default + + @staticmethod + def _parse_bool(raw_value: object | None, *, default: bool) -> bool: + if raw_value is None: + return default + try: + return BotConfigService._parse_bool_token(str(raw_value)) + except ValueError: + return default + + @staticmethod + def _parse_bool_token(raw_value: str) -> bool: + normalized = raw_value.strip().lower() + if normalized in {"1", "true", "on", "yes", "y", "да", "вкл"}: + return True + if normalized in {"0", "false", "off", "no", "n", "нет", "выкл"}: + return False + raise ValueError("Введите `on` или `off`.") + + @staticmethod + def _parse_non_negative_int(raw_value: str, label: str) -> int: + if not raw_value.strip().isdigit(): + raise ValueError(f"{label} должен быть целым неотрицательным числом.") + return int(raw_value.strip()) + + @staticmethod + def _parse_payment_plans(raw_value: str) -> list[PaymentPlanSettings]: + values: list[PaymentPlanSettings] = [] + for raw_part in raw_value.split(","): + part = raw_part.strip() + if not part: + continue + + fragments = [fragment.strip() for fragment in part.split(":")] + if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit(): + raise ValueError( + "Тарифы должны быть в формате `дни:цена`, например `30:250,180:600`." + ) + + days = int(fragments[0]) + amount_rub = int(fragments[1]) + if days <= 0 or amount_rub <= 0: + raise ValueError("У тарифов дни и цена должны быть больше нуля.") + + values.append( + PaymentPlanSettings( + code=f"{days}d", + days=days, + amount_rub=amount_rub, + ) + ) + + if not values: + raise ValueError("Нужно указать хотя бы один тариф.") + + return sorted(values, key=lambda item: item.days) diff --git a/app/services/notification_service.py b/app/services/notification_service.py new file mode 100644 index 0000000..1772e93 --- /dev/null +++ b/app/services/notification_service.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime +from pathlib import Path + +from aiogram import Bot +from aiogram.types import FSInputFile +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.db.base import utcnow +from app.db.models import SubscriptionNotification +from app.services.bot_config_service import BotConfigSnapshot, StaticBotConfigService +from app.services.sync_service import SyncService +from app.utils.formatters import format_datetime_with_days_left + +logger = logging.getLogger(__name__) + +NOTIFICATION_TYPE_EXPIRE_SOON = "EXPIRE_SOON" + + +class SubscriptionNotificationService: + def __init__( + self, + *, + session_factory: async_sessionmaker[AsyncSession], + sync_service: SyncService, + config_service: StaticBotConfigService, + project_root: Path, + ) -> None: + self._session_factory = session_factory + self._sync_service = sync_service + self._config_service = config_service + self._project_root = project_root + + async def _get_config(self) -> BotConfigSnapshot: + return await self._config_service.get_snapshot() + + def _resolve_image_path(self, config: BotConfigSnapshot) -> Path | None: + if not config.bot_start_image_enabled: + return None + + raw_path = config.bot_start_image_path.strip() + default_path = self._project_root / "assets" / "main.png" + + if not raw_path: + return default_path if default_path.is_file() else None + + image_path = Path(raw_path).expanduser() + if not image_path.is_absolute(): + image_path = self._project_root / image_path + + return image_path if image_path.is_file() else None + + async def _already_notified( + self, + session: AsyncSession, + *, + remnawave_user_id: int, + notification_type: str, + expire_at_snapshot: datetime, + ) -> bool: + existing = await session.scalar( + select(SubscriptionNotification.id).where( + SubscriptionNotification.remnawave_user_id == remnawave_user_id, + SubscriptionNotification.notification_type == notification_type, + SubscriptionNotification.expire_at_snapshot == expire_at_snapshot, + ) + ) + return existing is not None + + async def _record_notification( + self, + session: AsyncSession, + *, + remnawave_user_id: int, + telegram_id: int, + notification_type: str, + expire_at_snapshot: datetime, + ) -> None: + session.add( + SubscriptionNotification( + remnawave_user_id=remnawave_user_id, + telegram_id=telegram_id, + notification_type=notification_type, + expire_at_snapshot=expire_at_snapshot, + sent_at=utcnow(), + ) + ) + + async def check_and_notify(self, bot: Bot) -> int: + config = await self._get_config() + if not config.notification_enabled: + return 0 + + days_list = config.notification_days_list + if not days_list: + return 0 + + total_sent = 0 + image_path = self._resolve_image_path(config) + + for days_before in days_list: + expiring = await self._sync_service.get_expiring_users( + days_before=days_before, + tolerance_hours=max(config.notification_check_interval_hours, 1), + ) + + for rw_user_id, telegram_id, expire_at in expiring: + if telegram_id is None: + continue + + try: + sent = await self._send_notification( + bot, + rw_user_id=rw_user_id, + telegram_id=telegram_id, + expire_at=expire_at, + days_before=days_before, + brand_name=config.bot_brand_name, + image_path=image_path, + ) + if sent: + total_sent += 1 + except Exception: + logger.exception( + "Failed to send expiration notification to telegram_id=%s", + telegram_id, + ) + + return total_sent + + async def _send_notification( + self, + bot: Bot, + *, + rw_user_id: int, + telegram_id: int, + expire_at: datetime, + days_before: int, + brand_name: str, + image_path: Path | None, + ) -> bool: + notification_type = f"{NOTIFICATION_TYPE_EXPIRE_SOON}_{days_before}d" + + async with self._session_factory() as session: + if await self._already_notified( + session, + remnawave_user_id=rw_user_id, + notification_type=notification_type, + expire_at_snapshot=expire_at, + ): + return False + + text = self._build_notification_text( + brand_name=brand_name, + expire_at=expire_at, + days_before=days_before, + ) + + try: + if image_path is not None: + await bot.send_photo( + chat_id=telegram_id, + photo=FSInputFile(str(image_path)), + caption=text, + ) + else: + await bot.send_message( + chat_id=telegram_id, + text=text, + ) + except Exception: + logger.warning( + "Cannot send notification to telegram_id=%s (blocked or unavailable)", + telegram_id, + ) + return False + + await self._record_notification( + session, + remnawave_user_id=rw_user_id, + telegram_id=telegram_id, + notification_type=notification_type, + expire_at_snapshot=expire_at, + ) + await session.commit() + + return True + + @staticmethod + def _build_notification_text( + *, + brand_name: str, + expire_at: datetime, + days_before: int, + ) -> str: + import html as html_module + + if days_before <= 1: + urgency = "⚠️ Ваша подписка истекает завтра!" + else: + urgency = f"⏰ Ваша подписка истекает через {days_before} дн." + + lines = [ + f"{html_module.escape(brand_name)}", + "", + urgency, + "", + ( + "
" + f"Дата истечения: {format_datetime_with_days_left(expire_at)}\n\n" + "Чтобы продлить доступ, откройте бот и выберите тариф." + "
" + ), + "", + "Нажмите /start чтобы открыть меню и продлить подписку.", + ] + return "\n".join(lines) + + async def run_periodic(self, bot: Bot, *, default_interval_hours: int = 6) -> None: + logger.info("Subscription notification periodic task started") + while True: + try: + config = await self._get_config() + interval_hours = max(config.notification_check_interval_hours, 1) + except Exception: + interval_hours = max(default_interval_hours, 1) + + try: + total = await self.check_and_notify(bot) + if total > 0: + logger.info("Sent %d subscription expiration notifications", total) + except Exception: + logger.exception("Error in subscription notification check") + + await asyncio.sleep(interval_hours * 3600) diff --git a/app/services/payment_service.py b/app/services/payment_service.py new file mode 100644 index 0000000..a48ca82 --- /dev/null +++ b/app/services/payment_service.py @@ -0,0 +1,983 @@ +from __future__ import annotations + +import html +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from math import ceil +from uuid import uuid4 + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.config import PaymentPlanSettings, Settings +from app.db.base import utcnow +from app.db.models import PaymentOrder, ReferralBonus, ReferralInvite, TelegramUser +from app.schemas.remnawave import RemnawaveUser +from app.services.bot_config_service import BotConfigSnapshot, StaticBotConfigService +from app.services.remnawave_client import RemnawaveApiClient, RemnawaveApiError +from app.services.sync_service import SyncService + + +PENDING_PAYMENT_STATUS = "PENDING" +REVIEW_PAYMENT_STATUS = "REVIEW" +PROCESSING_PAYMENT_STATUS = "PROCESSING" +REJECTED_PAYMENT_STATUS = "REJECTED" +FULFILLED_PAYMENT_STATUS = "FULFILLED" +REFERRAL_BONUS_PENDING_STATUS = "PENDING" +REFERRAL_BONUS_PROCESSING_STATUS = "PROCESSING" +REFERRAL_BONUS_APPLIED_STATUS = "APPLIED" + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class PaymentPlan: + code: str + title: str + description: str + duration_days: int + amount_rub: int + discount_percent: int + original_amount_rub: int + applied_referral_code: str = "" + traffic_limit_bytes: int = 0 + traffic_limit_strategy: str = "NO_RESET" + internal_squad_uuids: list[str] | None = None + external_squad_uuid: str | None = None + + @property + def is_ready(self) -> bool: + return bool(self.internal_squad_uuids) and self.duration_days > 0 and self.amount_rub > 0 + + +@dataclass(slots=True) +class CreatedPaymentOrder: + order_uuid: str + provision_username: str + plan: PaymentPlan + transfer_text: str + extends_existing_access: bool = False + + +@dataclass(slots=True) +class StoredPaymentOrder: + order_uuid: str + telegram_id: int + plan_title: str + plan_duration_days: int + amount_rub: int + provision_username: str + status: str + + +@dataclass(slots=True) +class IssuedAccess: + telegram_id: int + username: str + subscription_url: str + expire_at: datetime + short_uuid: str + remnawave_user_uuid: str + traffic_limit_bytes: int + referral_bonus: ReferralBonusGrant | None = None + is_renewal: bool = False + + +@dataclass(slots=True) +class ReferralBonusGrant: + inviter_telegram_id: int + bonus_days: int + status: str + total_applied_days: int = 0 + expire_at: datetime | None = None + applied_to_user_uuid: str | None = None + + +@dataclass(slots=True) +class AppliedReferralBonus: + telegram_id: int + total_bonus_days: int + expire_at: datetime + applied_to_user_uuid: str + + +@dataclass(slots=True) +class PaymentAdminStats: + pending_orders: int = 0 + review_orders: int = 0 + pending_referral_bonuses: int = 0 + + +class PaymentService: + def __init__( + self, + *, + settings: Settings, + session_factory: async_sessionmaker[AsyncSession], + remnawave_client: RemnawaveApiClient, + sync_service: SyncService, + config_service: StaticBotConfigService | None = None, + ) -> None: + self._settings = settings + self._session_factory = session_factory + self._remnawave_client = remnawave_client + self._sync_service = sync_service + self._config_service = config_service or StaticBotConfigService(settings) + + async def _get_config(self) -> BotConfigSnapshot: + return await self._config_service.get_snapshot() + + async def get_available_plans(self, *, telegram_id: int) -> list[PaymentPlan]: + config = await self._get_config() + applied_referral_code = await self._get_applied_referral_code( + telegram_id, + config=config, + ) + return [ + self._build_plan( + plan, + applied_referral_code=applied_referral_code, + config=config, + ) + for plan in config.payment_plans + ] + + async def create_order( + self, + *, + telegram_id: int, + username: str | None, + first_name: str | None, + last_name: str | None, + language_code: str | None, + plan_code: str, + ) -> CreatedPaymentOrder: + config = await self._get_config() + applied_referral_code = await self._get_applied_referral_code( + telegram_id, + config=config, + ) + plan = self._get_plan_by_code( + plan_code, + applied_referral_code=applied_referral_code, + config=config, + ) + if not plan.is_ready: + raise ValueError( + "Покупка не настроена. Проверьте PAYMENT_PLANS и PAYMENT_INTERNAL_SQUAD_UUIDS." + ) + + existing_accesses = await self._sync_service.get_cached_users_for_telegram(telegram_id) + order_uuid = str(uuid4()) + invoice_payload = f"manual:{plan.code}:{order_uuid}" + provision_username = self._build_provision_username( + config=config, + order_uuid=order_uuid, + telegram_id=telegram_id, + username=username, + first_name=first_name, + ) + + async with self._session_factory() as session: + user = await self._upsert_telegram_user( + session, + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + language_code=language_code, + ) + session.add( + PaymentOrder( + order_uuid=order_uuid, + telegram_user_id=user.id, + telegram_id=telegram_id, + plan_code=plan.code, + plan_title=plan.title, + plan_duration_days=plan.duration_days, + traffic_limit_bytes=plan.traffic_limit_bytes, + traffic_limit_strategy=plan.traffic_limit_strategy, + amount_stars=plan.amount_rub, + currency="RUB", + status=PENDING_PAYMENT_STATUS, + invoice_payload=invoice_payload, + provision_username=provision_username, + error_message=applied_referral_code or None, + ) + ) + await session.commit() + + return CreatedPaymentOrder( + order_uuid=order_uuid, + provision_username=provision_username, + plan=plan, + transfer_text=config.payment_transfer_text.strip(), + extends_existing_access=bool(existing_accesses), + ) + + async def get_order_for_user(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder: + async with self._session_factory() as session: + order = await session.scalar( + select(PaymentOrder).where( + PaymentOrder.order_uuid == order_uuid, + PaymentOrder.telegram_id == telegram_id, + ) + ) + if order is None: + raise ValueError("Заказ не найден.") + + return StoredPaymentOrder( + order_uuid=order.order_uuid, + telegram_id=order.telegram_id, + plan_title=order.plan_title, + plan_duration_days=order.plan_duration_days, + amount_rub=order.amount_stars, + provision_username=order.provision_username, + status=order.status, + ) + + async def mark_order_under_review(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder: + async with self._session_factory() as session: + order = await session.scalar( + select(PaymentOrder) + .where( + PaymentOrder.order_uuid == order_uuid, + PaymentOrder.telegram_id == telegram_id, + ) + .with_for_update() + ) + if order is None: + raise ValueError("Заказ не найден.") + if order.status == REVIEW_PAYMENT_STATUS: + raise ValueError("Чек уже отправлен на проверку.") + if order.status == PROCESSING_PAYMENT_STATUS: + raise ValueError("Заказ уже обрабатывается модератором.") + if order.status == FULFILLED_PAYMENT_STATUS: + raise ValueError("Этот заказ уже подтверждён.") + if order.status == REJECTED_PAYMENT_STATUS: + raise ValueError("Этот заказ уже отклонён. Создайте новый платёж.") + if order.status != PENDING_PAYMENT_STATUS: + raise ValueError("Некорректный статус заказа для отправки чека.") + + order.status = REVIEW_PAYMENT_STATUS + order.updated_at = utcnow() + await session.commit() + + return StoredPaymentOrder( + order_uuid=order.order_uuid, + telegram_id=order.telegram_id, + plan_title=order.plan_title, + plan_duration_days=order.plan_duration_days, + amount_rub=order.amount_stars, + provision_username=order.provision_username, + status=order.status, + ) + + async def revert_order_to_pending(self, *, order_uuid: str, telegram_id: int) -> None: + async with self._session_factory() as session: + order = await session.scalar( + select(PaymentOrder) + .where( + PaymentOrder.order_uuid == order_uuid, + PaymentOrder.telegram_id == telegram_id, + ) + .with_for_update() + ) + if order is None or order.status != REVIEW_PAYMENT_STATUS: + return + + order.status = PENDING_PAYMENT_STATUS + order.updated_at = utcnow() + await session.commit() + + async def approve_order(self, *, order_uuid: str) -> IssuedAccess: + order = await self._claim_order_for_approval(order_uuid) + config = await self._get_config() + is_renewal = False + + try: + remote_user: RemnawaveUser | None = None + if order.remnawave_user_uuid: + try: + remote_user = await self._remnawave_client.get_user_by_uuid( + order.remnawave_user_uuid + ) + except RemnawaveApiError as exc: + if exc.status_code != 404: + raise + remote_user = None + + if remote_user is None: + try: + remote_user = await self._remnawave_client.get_user_by_username( + order.provision_username + ) + except RemnawaveApiError as exc: + if exc.status_code != 404: + raise + + if remote_user is None: + remote_users = await self._remnawave_client.get_users_by_telegram_id( + order.telegram_id + ) + remote_user = self._select_referral_bonus_target(remote_users) + + if remote_user is None: + remote_user = await self._create_remnawave_user(order, config=config) + else: + is_renewal = True + remote_user = await self._extend_remnawave_user( + order, + remote_user, + config=config, + ) + except RemnawaveApiError as exc: + await self._restore_review_after_failed_approval(order.order_uuid, exc.message) + raise + + await self._save_fulfilled_order(order.order_uuid, remote_user) + own_bonus = None + try: + own_bonus = await self.apply_pending_referral_bonuses( + telegram_id=order.telegram_id, + refresh_cache=False, + ) + except RemnawaveApiError as exc: + logger.warning( + "Could not apply pending referral bonus for telegram_id=%s: %s", + order.telegram_id, + exc.message, + ) + + try: + await self._sync_service.refresh_cached_users_for_telegram( + telegram_id=order.telegram_id, + ) + except RemnawaveApiError: + # Access is already issued at this point; cache can be refreshed later. + pass + + expire_at = remote_user.expire_at + if own_bonus is not None and own_bonus.applied_to_user_uuid == str(remote_user.uuid): + expire_at = own_bonus.expire_at + + referral_bonus = await self._grant_referral_bonus(order, config=config) + + return IssuedAccess( + telegram_id=order.telegram_id, + username=remote_user.username, + subscription_url=remote_user.subscription_url, + expire_at=expire_at, + short_uuid=remote_user.short_uuid, + remnawave_user_uuid=str(remote_user.uuid), + traffic_limit_bytes=remote_user.traffic_limit_bytes, + referral_bonus=referral_bonus, + is_renewal=is_renewal, + ) + + async def reject_order(self, *, order_uuid: str, reason: str | None = None) -> StoredPaymentOrder: + async with self._session_factory() as session: + order = await session.scalar( + select(PaymentOrder) + .where(PaymentOrder.order_uuid == order_uuid) + .with_for_update() + ) + if order is None: + raise ValueError("Заказ не найден.") + if order.status == FULFILLED_PAYMENT_STATUS: + raise ValueError("Подтверждённый заказ нельзя отклонить.") + if order.status == PROCESSING_PAYMENT_STATUS: + raise ValueError("Заказ уже обрабатывается другим модератором.") + if order.status == PENDING_PAYMENT_STATUS: + raise ValueError("Чек ещё не отправлен на проверку.") + if order.status == REJECTED_PAYMENT_STATUS: + raise ValueError("Заказ уже отклонён.") + + order.status = REJECTED_PAYMENT_STATUS + order.error_message = reason or "Платёж отклонён" + order.updated_at = utcnow() + await session.commit() + + return StoredPaymentOrder( + order_uuid=order.order_uuid, + telegram_id=order.telegram_id, + plan_title=order.plan_title, + plan_duration_days=order.plan_duration_days, + amount_rub=order.amount_stars, + provision_username=order.provision_username, + status=order.status, + ) + + async def get_referral_code_for_order(self, *, order_uuid: str) -> str: + order = await self._get_order(order_uuid) + return order.error_message or "" + + def build_transfer_text(self, *, order: CreatedPaymentOrder) -> str: + lines = [ + "Оплата тарифа", + "", + f"{html.escape(order.plan.title)}", + html.escape(order.plan.description), + "", + f"Сумма к переводу: {order.plan.amount_rub} ₽", + ] + if order.plan.applied_referral_code: + lines.append( + f"Реферальный код: {order.plan.applied_referral_code} " + f"(-{order.plan.discount_percent}%)" + ) + if order.extends_existing_access: + lines.extend( + [ + "", + ( + "
" + "У вас уже есть доступ. После подтверждения оплаты бот автоматически " + "продлит текущую подписку на выбранный срок." + "
" + ), + ] + ) + lines.extend( + [ + "", + "Реквизиты", + ( + "
" + f"{html.escape(order.transfer_text or 'Укажите реквизиты в PAYMENT_TRANSFER_TEXT.')}" + "
" + ), + "", + "После перевода нажмите кнопку ниже и отправьте чек следующим сообщением.", + ] + ) + return "\n".join(lines) + + def _get_plan_by_code( + self, + plan_code: str, + *, + applied_referral_code: str, + config: BotConfigSnapshot, + ) -> PaymentPlan: + for plan in config.payment_plans: + if plan.code == plan_code: + return self._build_plan( + plan, + applied_referral_code=applied_referral_code, + config=config, + ) + + raise ValueError("Тариф не найден.") + + def _build_plan( + self, + plan_settings: PaymentPlanSettings, + *, + applied_referral_code: str, + config: BotConfigSnapshot, + ) -> PaymentPlan: + discount_percent = max(config.referral_discount_percent, 0) if applied_referral_code else 0 + discounted_amount = self._apply_discount( + amount_rub=plan_settings.amount_rub, + discount_percent=discount_percent, + ) + traffic_limit_bytes = max(config.payment_plan_traffic_limit_gb, 0) * 1024**3 + return PaymentPlan( + code=plan_settings.code, + title=f"{config.bot_brand_name} на {plan_settings.days} дней", + description=f"Доступ к VPN на {plan_settings.days} дней", + duration_days=plan_settings.days, + amount_rub=discounted_amount, + discount_percent=discount_percent, + original_amount_rub=plan_settings.amount_rub, + applied_referral_code=applied_referral_code, + traffic_limit_bytes=traffic_limit_bytes, + traffic_limit_strategy=config.payment_plan_traffic_reset_period.strip() or "NO_RESET", + internal_squad_uuids=config.payment_internal_squad_uuids, + external_squad_uuid=config.payment_external_squad_uuid_normalized, + ) + + @staticmethod + def _apply_discount(*, amount_rub: int, discount_percent: int) -> int: + if discount_percent <= 0: + return amount_rub + return max(1, ceil(amount_rub * (100 - discount_percent) / 100)) + + async def _get_applied_referral_code( + self, + telegram_id: int, + *, + config: BotConfigSnapshot | None = None, + ) -> str: + resolved_config = config or await self._get_config() + if not resolved_config.referral_enabled: + return "" + summary = await self._sync_service.get_referral_summary(telegram_id) + return summary.applied_referral_code.strip().upper() + + async def apply_pending_referral_bonuses( + self, + *, + telegram_id: int, + refresh_cache: bool = True, + ) -> AppliedReferralBonus | None: + config = await self._get_config() + if not config.referral_enabled: + return None + + claimed_bonuses = await self._claim_pending_referral_bonuses(telegram_id) + if not claimed_bonuses: + return None + + total_bonus_days = sum(max(bonus.bonus_days, 0) for bonus in claimed_bonuses) + if total_bonus_days <= 0: + await self._restore_claimed_referral_bonuses( + [bonus.id for bonus in claimed_bonuses], + status=REFERRAL_BONUS_PENDING_STATUS, + ) + return None + + remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id) + target_user = self._select_referral_bonus_target(remote_users) + if target_user is None: + await self._restore_claimed_referral_bonuses( + [bonus.id for bonus in claimed_bonuses], + status=REFERRAL_BONUS_PENDING_STATUS, + ) + return None + + current_expire_at = target_user.expire_at + if current_expire_at.tzinfo is None: + current_expire_at = current_expire_at.replace(tzinfo=timezone.utc) + next_expire_at = max(current_expire_at, datetime.now(timezone.utc)) + timedelta( + days=total_bonus_days + ) + + try: + updated_user = await self._remnawave_client.update_user( + { + "uuid": str(target_user.uuid), + "expireAt": next_expire_at.isoformat().replace("+00:00", "Z"), + "description": ( + f"Referral bonus applied by Telegram bot " + f"({total_bonus_days} days)" + ), + } + ) + except RemnawaveApiError: + await self._restore_claimed_referral_bonuses( + [bonus.id for bonus in claimed_bonuses], + status=REFERRAL_BONUS_PENDING_STATUS, + ) + raise + + await self._mark_referral_bonuses_applied( + [bonus.id for bonus in claimed_bonuses], + applied_to_user_uuid=str(updated_user.uuid), + ) + + if refresh_cache: + try: + await self._sync_service.refresh_cached_users_for_telegram( + telegram_id=telegram_id, + ) + except RemnawaveApiError: + pass + + return AppliedReferralBonus( + telegram_id=telegram_id, + total_bonus_days=total_bonus_days, + expire_at=updated_user.expire_at, + applied_to_user_uuid=str(updated_user.uuid), + ) + + async def get_admin_stats(self) -> PaymentAdminStats: + async with self._session_factory() as session: + pending_orders = await session.scalar( + select(func.count(PaymentOrder.id)).where( + PaymentOrder.status == PENDING_PAYMENT_STATUS + ) + ) + review_orders = await session.scalar( + select(func.count(PaymentOrder.id)).where( + PaymentOrder.status == REVIEW_PAYMENT_STATUS + ) + ) + pending_referral_bonuses = await session.scalar( + select(func.count(ReferralBonus.id)).where( + ReferralBonus.status == REFERRAL_BONUS_PENDING_STATUS + ) + ) + + return PaymentAdminStats( + pending_orders=int(pending_orders or 0), + review_orders=int(review_orders or 0), + pending_referral_bonuses=int(pending_referral_bonuses or 0), + ) + + async def _get_order(self, order_uuid: str) -> PaymentOrder: + async with self._session_factory() as session: + order = await session.scalar( + select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid) + ) + if order is None: + raise ValueError("Заказ не найден.") + return order + + async def _claim_order_for_approval(self, order_uuid: str) -> PaymentOrder: + async with self._session_factory() as session: + order = await session.scalar( + select(PaymentOrder) + .where(PaymentOrder.order_uuid == order_uuid) + .with_for_update() + ) + if order is None: + raise ValueError("Заказ не найден.") + if order.status == PENDING_PAYMENT_STATUS: + raise ValueError("Чек ещё не отправлен на проверку.") + if order.status == PROCESSING_PAYMENT_STATUS: + raise ValueError("Заказ уже обрабатывается другим модератором.") + if order.status == REJECTED_PAYMENT_STATUS: + raise ValueError("Заказ уже отклонён.") + if order.status == FULFILLED_PAYMENT_STATUS: + raise ValueError("Этот заказ уже подтверждён.") + if order.status != REVIEW_PAYMENT_STATUS: + raise ValueError("Некорректный статус заказа для подтверждения.") + + order.status = PROCESSING_PAYMENT_STATUS + order.updated_at = utcnow() + await session.commit() + return order + + async def _create_remnawave_user( + self, + order: PaymentOrder, + *, + config: BotConfigSnapshot, + ) -> RemnawaveUser: + expire_at = datetime.now(timezone.utc) + timedelta(days=order.plan_duration_days) + body: dict[str, object] = { + "username": order.provision_username, + "expireAt": expire_at.isoformat().replace("+00:00", "Z"), + "trafficLimitBytes": order.traffic_limit_bytes, + "trafficLimitStrategy": order.traffic_limit_strategy, + "telegramId": order.telegram_id, + "description": f"Created by Telegram bot manual payment {order.order_uuid}", + "activeInternalSquads": config.payment_internal_squad_uuids, + } + if config.payment_user_tag_normalized: + body["tag"] = config.payment_user_tag_normalized + if config.payment_external_squad_uuid_normalized: + body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized + return await self._remnawave_client.create_user(body) + + async def _extend_remnawave_user( + self, + order: PaymentOrder, + remote_user: RemnawaveUser, + *, + config: BotConfigSnapshot, + ) -> RemnawaveUser: + current_expire_at = remote_user.expire_at + if current_expire_at.tzinfo is None: + current_expire_at = current_expire_at.replace(tzinfo=timezone.utc) + + base_expire_at = max(current_expire_at, datetime.now(timezone.utc)) + next_expire_at = base_expire_at + timedelta(days=order.plan_duration_days) + + traffic_limit_bytes = order.traffic_limit_bytes + if traffic_limit_bytes <= 0: + traffic_limit_bytes = remote_user.traffic_limit_bytes + + body: dict[str, object] = { + "uuid": str(remote_user.uuid), + "username": remote_user.username, + "expireAt": next_expire_at.isoformat().replace("+00:00", "Z"), + "trafficLimitBytes": traffic_limit_bytes, + "trafficLimitStrategy": order.traffic_limit_strategy, + "telegramId": order.telegram_id, + "description": f"Updated by Telegram bot manual payment {order.order_uuid}", + "activeInternalSquads": config.payment_internal_squad_uuids, + } + if config.payment_user_tag_normalized: + body["tag"] = config.payment_user_tag_normalized + if config.payment_external_squad_uuid_normalized: + body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized + return await self._remnawave_client.update_user(body) + + async def _save_fulfilled_order(self, order_uuid: str, remote_user: RemnawaveUser) -> None: + async with self._session_factory() as session: + order = await session.scalar( + select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid) + ) + if order is None: + return + order.status = FULFILLED_PAYMENT_STATUS + order.remnawave_user_uuid = str(remote_user.uuid) + order.subscription_url = remote_user.subscription_url + order.error_message = None + order.paid_at = utcnow() + order.fulfilled_at = utcnow() + order.updated_at = utcnow() + await session.commit() + + async def _save_order_error(self, order_uuid: str, error_message: str) -> None: + async with self._session_factory() as session: + order = await session.scalar( + select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid) + ) + if order is None: + return + order.error_message = error_message + order.updated_at = utcnow() + await session.commit() + + async def _restore_review_after_failed_approval( + self, + order_uuid: str, + error_message: str, + ) -> None: + async with self._session_factory() as session: + order = await session.scalar( + select(PaymentOrder) + .where(PaymentOrder.order_uuid == order_uuid) + .with_for_update() + ) + if order is None: + return + + if order.status == PROCESSING_PAYMENT_STATUS: + order.status = REVIEW_PAYMENT_STATUS + order.error_message = error_message + order.updated_at = utcnow() + await session.commit() + + async def _grant_referral_bonus( + self, + order: PaymentOrder, + *, + config: BotConfigSnapshot, + ) -> ReferralBonusGrant | None: + if not config.referral_enabled: + return None + + bonus_days = max(config.referral_bonus_days, 0) + if bonus_days <= 0: + return None + + inviter_telegram_id = await self._get_inviter_telegram_id(order.telegram_id) + if inviter_telegram_id is None or inviter_telegram_id == order.telegram_id: + return None + + await self._create_referral_bonus( + inviter_telegram_id=inviter_telegram_id, + invited_telegram_id=order.telegram_id, + source_order_uuid=order.order_uuid, + bonus_days=bonus_days, + ) + + try: + applied_bonus = await self.apply_pending_referral_bonuses( + telegram_id=inviter_telegram_id, + ) + except RemnawaveApiError as exc: + logger.warning( + "Could not apply referral bonus for inviter=%s after order=%s: %s", + inviter_telegram_id, + order.order_uuid, + exc.message, + ) + applied_bonus = None + + if applied_bonus is None: + return ReferralBonusGrant( + inviter_telegram_id=inviter_telegram_id, + bonus_days=bonus_days, + status=REFERRAL_BONUS_PENDING_STATUS, + ) + + return ReferralBonusGrant( + inviter_telegram_id=inviter_telegram_id, + bonus_days=bonus_days, + status=REFERRAL_BONUS_APPLIED_STATUS, + total_applied_days=applied_bonus.total_bonus_days, + expire_at=applied_bonus.expire_at, + applied_to_user_uuid=applied_bonus.applied_to_user_uuid, + ) + + async def _get_inviter_telegram_id(self, invited_telegram_id: int) -> int | None: + async with self._session_factory() as session: + return await session.scalar( + select(ReferralInvite.inviter_telegram_id).where( + ReferralInvite.invited_telegram_id == invited_telegram_id + ) + ) + + async def _create_referral_bonus( + self, + *, + inviter_telegram_id: int, + invited_telegram_id: int, + source_order_uuid: str, + bonus_days: int, + ) -> None: + async with self._session_factory() as session: + existing = await session.scalar( + select(ReferralBonus).where( + ReferralBonus.source_order_uuid == source_order_uuid + ) + ) + if existing is not None: + return + + session.add( + ReferralBonus( + inviter_telegram_id=inviter_telegram_id, + invited_telegram_id=invited_telegram_id, + source_order_uuid=source_order_uuid, + bonus_days=bonus_days, + status=REFERRAL_BONUS_PENDING_STATUS, + ) + ) + await session.commit() + + async def _claim_pending_referral_bonuses(self, telegram_id: int) -> list[ReferralBonus]: + async with self._session_factory() as session: + bonuses = list( + ( + await session.scalars( + select(ReferralBonus) + .where( + ReferralBonus.inviter_telegram_id == telegram_id, + ReferralBonus.status == REFERRAL_BONUS_PENDING_STATUS, + ) + .order_by(ReferralBonus.created_at.asc(), ReferralBonus.id.asc()) + .with_for_update() + ) + ).all() + ) + if not bonuses: + return [] + + for bonus in bonuses: + bonus.status = REFERRAL_BONUS_PROCESSING_STATUS + bonus.updated_at = utcnow() + await session.commit() + return bonuses + + async def _restore_claimed_referral_bonuses( + self, + bonus_ids: list[int], + *, + status: str, + ) -> None: + if not bonus_ids: + return + + async with self._session_factory() as session: + bonuses = list( + ( + await session.scalars( + select(ReferralBonus) + .where(ReferralBonus.id.in_(bonus_ids)) + .with_for_update() + ) + ).all() + ) + for bonus in bonuses: + bonus.status = status + bonus.updated_at = utcnow() + await session.commit() + + async def _mark_referral_bonuses_applied( + self, + bonus_ids: list[int], + *, + applied_to_user_uuid: str, + ) -> None: + if not bonus_ids: + return + + async with self._session_factory() as session: + bonuses = list( + ( + await session.scalars( + select(ReferralBonus) + .where(ReferralBonus.id.in_(bonus_ids)) + .with_for_update() + ) + ).all() + ) + for bonus in bonuses: + bonus.status = REFERRAL_BONUS_APPLIED_STATUS + bonus.applied_to_user_uuid = applied_to_user_uuid + bonus.applied_at = utcnow() + bonus.updated_at = utcnow() + await session.commit() + + @staticmethod + def _select_referral_bonus_target( + remote_users: list[RemnawaveUser], + ) -> RemnawaveUser | None: + if not remote_users: + return None + + def _sort_key(user: RemnawaveUser) -> tuple[int, datetime, str]: + expire_at = user.expire_at + if expire_at.tzinfo is None: + expire_at = expire_at.replace(tzinfo=timezone.utc) + return (0 if user.status.upper() == "ACTIVE" else 1, expire_at, user.username) + + return sorted(remote_users, key=_sort_key)[0] + + async def _upsert_telegram_user( + self, + session: AsyncSession, + *, + telegram_id: int, + username: str | None, + first_name: str | None, + last_name: str | None, + language_code: str | None, + ) -> TelegramUser: + record = await session.scalar( + select(TelegramUser).where(TelegramUser.telegram_id == telegram_id) + ) + + if record is None: + record = TelegramUser(telegram_id=telegram_id) + session.add(record) + + record.username = username + record.first_name = first_name + record.last_name = last_name + record.language_code = language_code + record.is_admin = self._settings.is_admin(telegram_id) + record.last_seen_at = utcnow() + record.updated_at = utcnow() + + await session.flush() + return record + + def _build_provision_username( + self, + *, + config: BotConfigSnapshot, + order_uuid: str, + telegram_id: int, + username: str | None, + first_name: str | None, + ) -> str: + base_name = username or first_name or "user" + normalized_name = "".join( + ch for ch in base_name if ch.isalnum() or ch in "_-" + ).strip("-_") + if not normalized_name: + normalized_name = "user" + + prefix = config.payment_username_prefix_normalized or "Oreol" + unique_suffix = order_uuid.replace("-", "")[:6] + provision_username = f"{prefix}-{telegram_id}-{unique_suffix}-{normalized_name}" + return provision_username[:36] diff --git a/app/services/remnawave_client.py b/app/services/remnawave_client.py new file mode 100644 index 0000000..b60be9e --- /dev/null +++ b/app/services/remnawave_client.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +import httpx + +from app.schemas.remnawave import PaginatedUsers, RemnawaveUser, ResolvedUser, SubscriptionRequestHistory + + +UUID_RE = re.compile( + r"^[0-9a-fA-F]{8}-" + r"[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{12}$" +) + + +class RemnawaveApiError(RuntimeError): + def __init__(self, *, status_code: int, message: str, payload: Any | None = None) -> None: + self.status_code = status_code + self.message = message + self.payload = payload + super().__init__(message) + + +class RemnawaveApiClient: + def __init__( + self, + *, + base_url: str, + api_token: str, + caddy_api_key: str = "", + timeout_seconds: float = 20.0, + client: httpx.AsyncClient | None = None, + ) -> None: + self._managed_client = client is None + self._client = client or httpx.AsyncClient( + base_url=self.normalize_base_url(base_url), + timeout=timeout_seconds, + headers=self._build_headers( + base_url=base_url, + api_token=api_token, + caddy_api_key=caddy_api_key, + ), + ) + + @staticmethod + def normalize_base_url(base_url: str) -> str: + normalized = base_url.strip().rstrip("/") + if not normalized.endswith("/api"): + normalized = f"{normalized}/api" + return f"{normalized}/" + + @staticmethod + def unwrap_payload(payload: Any) -> Any: + if isinstance(payload, Mapping) and "response" in payload: + return payload["response"] + return payload + + @staticmethod + def _build_headers( + *, + base_url: str, + api_token: str, + caddy_api_key: str, + ) -> dict[str, str]: + headers: dict[str, str] = {"Accept": "application/json"} + if api_token: + headers["Authorization"] = ( + api_token if api_token.startswith("Bearer ") else f"Bearer {api_token}" + ) + if caddy_api_key: + headers["X-Api-Key"] = caddy_api_key + if base_url.startswith("http://"): + headers["x-forwarded-proto"] = "https" + headers["x-forwarded-for"] = "127.0.0.1" + return headers + + async def close(self) -> None: + if self._managed_client: + await self._client.aclose() + + async def get_users_by_telegram_id(self, telegram_id: int) -> list[RemnawaveUser]: + payload = await self._request_json("GET", f"users/by-telegram-id/{telegram_id}") + return [RemnawaveUser.model_validate(item) for item in payload] + + async def get_user_by_uuid(self, user_uuid: str) -> RemnawaveUser: + payload = await self._request_json("GET", f"users/{user_uuid}") + return RemnawaveUser.model_validate(payload) + + async def get_user_by_short_uuid(self, short_uuid: str) -> RemnawaveUser: + payload = await self._request_json("GET", f"users/by-short-uuid/{short_uuid}") + return RemnawaveUser.model_validate(payload) + + async def get_user_by_username(self, username: str) -> RemnawaveUser: + payload = await self._request_json("GET", f"users/by-username/{username}") + return RemnawaveUser.model_validate(payload) + + async def create_user(self, body: dict[str, Any]) -> RemnawaveUser: + payload = await self._request_json("POST", "users", json=body) + return RemnawaveUser.model_validate(payload) + + async def update_user(self, body: dict[str, Any]) -> RemnawaveUser: + payload = await self._request_json("PATCH", "users", json=body) + return RemnawaveUser.model_validate(payload) + + async def get_all_users(self, *, start: int = 0, size: int = 100) -> PaginatedUsers: + payload = await self._request_json( + "GET", + "users", + params={"start": start, "size": size}, + ) + return PaginatedUsers.model_validate(payload) + + async def get_user_subscription_history(self, user_uuid: str) -> SubscriptionRequestHistory: + payload = await self._request_json( + "GET", + f"users/{user_uuid}/subscription-request-history", + ) + return SubscriptionRequestHistory.model_validate(payload) + + async def resolve_user(self, identifier: str) -> ResolvedUser: + body: dict[str, Any] + + if UUID_RE.match(identifier): + body = {"uuid": identifier} + elif identifier.isdigit(): + body = {"id": int(identifier)} + else: + try: + payload = await self._request_json( + "POST", + "users/resolve", + json={"shortUuid": identifier}, + ) + return ResolvedUser.model_validate(payload) + except RemnawaveApiError as exc: + if exc.status_code != 404: + raise + body = {"username": identifier} + + payload = await self._request_json("POST", "users/resolve", json=body) + return ResolvedUser.model_validate(payload) + + async def _request_json( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + ) -> Any: + response = await self._client.request( + method=method, + url=path.lstrip("/"), + params=params, + json=json, + ) + + if response.is_error: + raise self._build_error(response) + + try: + payload = response.json() + except ValueError: + return response.text + + return self.unwrap_payload(payload) + + def _build_error(self, response: httpx.Response) -> RemnawaveApiError: + try: + payload = response.json() + except ValueError: + payload = None + + message = "Unknown Remnawave API error" + if isinstance(payload, Mapping): + for key in ("message", "error", "code"): + candidate = payload.get(key) + if candidate: + message = str(candidate) + break + + error_code = payload.get("errorCode") + if error_code: + message = f"{message} [{error_code}]" + + error_items = payload.get("errors") + if isinstance(error_items, list): + details: list[str] = [] + for item in error_items: + if not isinstance(item, Mapping): + continue + + path_value = item.get("path") + if isinstance(path_value, list): + path_text = ".".join(str(part) for part in path_value if part is not None) + elif path_value is not None: + path_text = str(path_value) + else: + path_text = "" + + detail_message = str(item.get("message") or item.get("code") or "").strip() + if not detail_message: + continue + + if path_text: + details.append(f"{path_text}: {detail_message}") + else: + details.append(detail_message) + + if details: + message = f"{message} | {'; '.join(details)}" + elif response.text: + message = response.text + + return RemnawaveApiError( + status_code=response.status_code, + message=message, + payload=payload, + ) diff --git a/app/services/support_ticket_service.py b/app/services/support_ticket_service.py new file mode 100644 index 0000000..6978583 --- /dev/null +++ b/app/services/support_ticket_service.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.db.base import utcnow +from app.db.models import SupportTicket + + +class SupportTicketService: + def __init__(self, *, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + + async def create_ticket( + self, + *, + public_id: str, + telegram_id: int, + username: str | None, + display_name: str, + user_message: str, + support_chat_id: int, + support_thread_id: int | None, + support_message_id: int, + ) -> SupportTicket: + async with self._session_factory() as session: + ticket = SupportTicket( + public_id=public_id, + telegram_id=telegram_id, + username=username, + display_name=display_name, + user_message=user_message, + support_chat_id=support_chat_id, + support_thread_id=support_thread_id, + support_message_id=support_message_id, + status="OPEN", + ) + session.add(ticket) + await session.commit() + await session.refresh(ticket) + return ticket + + async def get_ticket_by_support_message( + self, + *, + support_chat_id: int, + support_message_id: int, + ) -> SupportTicket | None: + async with self._session_factory() as session: + return await session.scalar( + select(SupportTicket).where( + SupportTicket.support_chat_id == support_chat_id, + SupportTicket.support_message_id == support_message_id, + ) + ) + + async def mark_answered(self, *, ticket_id: int) -> None: + async with self._session_factory() as session: + ticket = await session.get(SupportTicket, ticket_id) + if ticket is None: + return + + ticket.status = "ANSWERED" + ticket.updated_at = utcnow() + await session.commit() + + async def get_open_tickets_count(self) -> int: + async with self._session_factory() as session: + total = await session.scalar( + select(func.count(SupportTicket.id)).where(SupportTicket.status == "OPEN") + ) + return int(total or 0) diff --git a/app/services/sync_service.py b/app/services/sync_service.py new file mode 100644 index 0000000..2bff8fd --- /dev/null +++ b/app/services/sync_service.py @@ -0,0 +1,811 @@ +from __future__ import annotations + +import secrets +import math +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from sqlalchemy import delete, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.config import Settings +from app.db.base import utcnow +from app.db.models import ( + ReferralCode, + ReferralInvite, + InternalSquad, + RemnawaveUser, + RemnawaveUserInternalSquad, + SubscriptionRequestLog, + TelegramUser, +) +from app.schemas.remnawave import RemnawaveUser as RemoteRemnawaveUser +from app.schemas.remnawave import SubscriptionRequestHistory +from app.services.remnawave_client import RemnawaveApiClient + + +@dataclass(slots=True) +class CachedUserView: + record: RemnawaveUser + internal_squads: list[str] = field(default_factory=list) + recent_requests: list[SubscriptionRequestLog] = field(default_factory=list) + + +@dataclass(slots=True) +class ReferralSummary: + total_invited: int = 0 + referral_code: str = "" + applied_referral_code: str = "" + recent_names: list[str] = field(default_factory=list) + + +@dataclass(slots=True) +class AdminStats: + total_telegram_users: int = 0 + total_cached_users: int = 0 + active_cached_users: int = 0 + + +@dataclass(slots=True) +class AdminTelegramUserListItem: + telegram_id: int + username: str | None + first_name: str | None + last_name: str | None + language_code: str | None + is_admin: bool + is_blocked: bool + last_seen_at: datetime + created_at: datetime + + @property + def display_name(self) -> str: + value = " ".join(part for part in [self.first_name, self.last_name] if part).strip() + return value or self.username or str(self.telegram_id) + + +@dataclass(slots=True) +class AdminTelegramUsersPage: + items: list[AdminTelegramUserListItem] = field(default_factory=list) + page: int = 1 + page_size: int = 8 + total_items: int = 0 + total_pages: int = 1 + + +@dataclass(slots=True) +class AdminTelegramUserProfile: + user: TelegramUser + referral_code: str = "" + applied_referral_code: str = "" + invited_count: int = 0 + recent_invited_names: list[str] = field(default_factory=list) + accesses: list[CachedUserView] = field(default_factory=list) + + +class SyncService: + def __init__( + self, + *, + settings: Settings, + session_factory: async_sessionmaker[AsyncSession], + remnawave_client: RemnawaveApiClient, + ) -> None: + self._settings = settings + self._session_factory = session_factory + self._remnawave_client = remnawave_client + + async def is_user_blocked(self, telegram_id: int) -> bool: + async with self._session_factory() as session: + is_blocked = await session.scalar( + select(TelegramUser.is_blocked).where( + TelegramUser.telegram_id == telegram_id + ) + ) + return bool(is_blocked) + + async def block_telegram_user(self, telegram_id: int) -> None: + async with self._session_factory() as session: + user = await session.scalar( + select(TelegramUser).where(TelegramUser.telegram_id == telegram_id) + ) + if user is not None: + user.is_blocked = True + user.updated_at = utcnow() + await session.commit() + + async def unblock_telegram_user(self, telegram_id: int) -> None: + async with self._session_factory() as session: + user = await session.scalar( + select(TelegramUser).where(TelegramUser.telegram_id == telegram_id) + ) + if user is not None: + user.is_blocked = False + user.updated_at = utcnow() + await session.commit() + + async def get_expiring_users( + self, + *, + days_before: int, + tolerance_hours: int = 6, + ) -> list[tuple[int, int, datetime]]: + """Return (remnawave_user.id, telegram_id, expire_at) for users expiring within window.""" + from datetime import timedelta + + now = utcnow() + window_start = now + timedelta(days=days_before) - timedelta(hours=tolerance_hours) + window_end = now + timedelta(days=days_before) + timedelta(hours=tolerance_hours) + + async with self._session_factory() as session: + rows = await session.execute( + select( + RemnawaveUser.id, + RemnawaveUser.telegram_id, + RemnawaveUser.expire_at, + ) + .where( + RemnawaveUser.status == "ACTIVE", + RemnawaveUser.telegram_id.isnot(None), + RemnawaveUser.expire_at >= window_start, + RemnawaveUser.expire_at <= window_end, + ) + ) + return [(row[0], row[1], row[2]) for row in rows.all()] + + async def register_telegram_user( + self, + *, + telegram_id: int, + username: str | None, + first_name: str | None, + last_name: str | None, + language_code: str | None, + ) -> None: + async with self._session_factory() as session: + await self._upsert_telegram_user( + session, + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + language_code=language_code, + ) + await self._get_or_create_referral_code(session, telegram_id=telegram_id) + await session.commit() + + async def apply_referral_code( + self, + *, + telegram_id: int, + username: str | None, + first_name: str | None, + last_name: str | None, + language_code: str | None, + referral_code: str, + ) -> str: + async with self._session_factory() as session: + invited = await self._upsert_telegram_user( + session, + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + language_code=language_code, + ) + await self._get_or_create_referral_code(session, telegram_id=telegram_id) + + existing = await session.scalar( + select(ReferralInvite).where( + ReferralInvite.invited_telegram_id == telegram_id + ) + ) + if existing is not None: + raise ValueError("Реферальный код уже применен к вашему аккаунту.") + + inviter_telegram_id = await self._find_inviter_by_referral_code(session, referral_code) + if inviter_telegram_id is None: + raise ValueError("Реферальный код не найден.") + if inviter_telegram_id == telegram_id: + raise ValueError("Нельзя использовать собственный реферальный код.") + + invite_created = await self._upsert_referral_invite( + session, + invited=invited, + referred_by_telegram_id=inviter_telegram_id, + ) + if not invite_created: + raise ValueError("Не удалось применить реферальный код.") + + await session.commit() + return referral_code.strip().upper() + + async def get_referral_summary(self, telegram_id: int) -> ReferralSummary: + async with self._session_factory() as session: + referral_code = await self._get_or_create_referral_code( + session, + telegram_id=telegram_id, + ) + await session.commit() + total_invited = await session.scalar( + select(func.count(ReferralInvite.id)).where( + ReferralInvite.inviter_telegram_id == telegram_id + ) + ) + recent_rows = await session.execute( + select(ReferralInvite.invited_display_name) + .where(ReferralInvite.inviter_telegram_id == telegram_id) + .order_by(ReferralInvite.created_at.desc()) + .limit(5) + ) + recent_names = list(recent_rows.scalars().all()) + applied_referral_code = await session.scalar( + select(ReferralCode.code) + .join( + ReferralInvite, + ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id, + ) + .where(ReferralInvite.invited_telegram_id == telegram_id) + ) + + return ReferralSummary( + total_invited=int(total_invited or 0), + referral_code=referral_code, + applied_referral_code=applied_referral_code or "", + recent_names=recent_names, + ) + + async def get_admin_stats(self) -> AdminStats: + async with self._session_factory() as session: + total_telegram_users = await session.scalar( + select(func.count(TelegramUser.id)) + ) + total_cached_users = await session.scalar( + select(func.count(RemnawaveUser.id)) + ) + active_cached_users = await session.scalar( + select(func.count(RemnawaveUser.id)).where(RemnawaveUser.status == "ACTIVE") + ) + + return AdminStats( + total_telegram_users=int(total_telegram_users or 0), + total_cached_users=int(total_cached_users or 0), + active_cached_users=int(active_cached_users or 0), + ) + + async def get_admin_telegram_users_page( + self, + *, + page: int, + page_size: int = 8, + ) -> AdminTelegramUsersPage: + resolved_page_size = max(1, min(page_size, 20)) + + async with self._session_factory() as session: + total_items = int( + await session.scalar(select(func.count(TelegramUser.id))) or 0 + ) + total_pages = max(1, math.ceil(total_items / resolved_page_size)) if total_items else 1 + resolved_page = min(max(page, 1), total_pages) + + rows = list( + ( + await session.scalars( + select(TelegramUser) + .order_by( + TelegramUser.last_seen_at.desc(), + TelegramUser.created_at.desc(), + TelegramUser.id.desc(), + ) + .offset((resolved_page - 1) * resolved_page_size) + .limit(resolved_page_size) + ) + ).all() + ) + + return AdminTelegramUsersPage( + items=[ + AdminTelegramUserListItem( + telegram_id=row.telegram_id, + username=row.username, + first_name=row.first_name, + last_name=row.last_name, + language_code=row.language_code, + is_admin=row.is_admin, + is_blocked=row.is_blocked, + last_seen_at=row.last_seen_at, + created_at=row.created_at, + ) + for row in rows + ], + page=resolved_page, + page_size=resolved_page_size, + total_items=total_items, + total_pages=total_pages, + ) + + async def get_admin_telegram_user_profile( + self, + *, + telegram_id: int, + ) -> AdminTelegramUserProfile | None: + async with self._session_factory() as session: + user = await session.scalar( + select(TelegramUser).where(TelegramUser.telegram_id == telegram_id) + ) + if user is None: + return None + + referral_code = await session.scalar( + select(ReferralCode.code).where(ReferralCode.telegram_id == telegram_id) + ) + invited_count = await session.scalar( + select(func.count(ReferralInvite.id)).where( + ReferralInvite.inviter_telegram_id == telegram_id + ) + ) + recent_rows = await session.execute( + select(ReferralInvite.invited_display_name) + .where(ReferralInvite.inviter_telegram_id == telegram_id) + .order_by(ReferralInvite.created_at.desc()) + .limit(5) + ) + recent_invited_names = list(recent_rows.scalars().all()) + applied_referral_code = await session.scalar( + select(ReferralCode.code) + .join( + ReferralInvite, + ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id, + ) + .where(ReferralInvite.invited_telegram_id == telegram_id) + ) + + accesses = await self.get_cached_users_for_telegram(telegram_id) + + return AdminTelegramUserProfile( + user=user, + referral_code=referral_code or "", + applied_referral_code=applied_referral_code or "", + invited_count=int(invited_count or 0), + recent_invited_names=recent_invited_names, + accesses=accesses, + ) + + async def sync_users_for_telegram( + self, + *, + telegram_id: int, + username: str | None, + first_name: str | None, + last_name: str | None, + language_code: str | None, + ) -> list[CachedUserView]: + remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id) + + async with self._session_factory() as session: + owner = await self._upsert_telegram_user( + session, + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + language_code=language_code, + ) + await self._sync_remote_users_for_telegram( + session, + telegram_id=telegram_id, + remote_users=remote_users, + owner=owner, + ) + + await session.commit() + + return await self.get_cached_users_for_telegram(telegram_id) + + async def refresh_cached_users_for_telegram(self, *, telegram_id: int) -> list[CachedUserView]: + remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id) + + async with self._session_factory() as session: + owner = await session.scalar( + select(TelegramUser).where(TelegramUser.telegram_id == telegram_id) + ) + await self._sync_remote_users_for_telegram( + session, + telegram_id=telegram_id, + remote_users=remote_users, + owner=owner, + ) + await session.commit() + + return await self.get_cached_users_for_telegram(telegram_id) + + async def link_user_by_short_uuid( + self, + *, + short_uuid: str, + telegram_id: int, + username: str | None, + first_name: str | None, + last_name: str | None, + language_code: str | None, + ) -> CachedUserView: + remote_user = await self._remnawave_client.get_user_by_short_uuid(short_uuid) + + if remote_user.telegram_id and remote_user.telegram_id != telegram_id: + raise ValueError("Этот аккаунт уже привязан к другому Telegram ID.") + + updated_user = await self._remnawave_client.update_user( + { + "uuid": str(remote_user.uuid), + "telegramId": telegram_id, + } + ) + + async with self._session_factory() as session: + owner = await self._upsert_telegram_user( + session, + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + language_code=language_code, + ) + await self._upsert_remnawave_user(session, updated_user, owner) + if self._settings.sync_subscription_history: + history = await self._remnawave_client.get_user_subscription_history( + str(updated_user.uuid) + ) + await self._upsert_history(session, str(updated_user.uuid), history) + await session.commit() + + user = await self.get_cached_user_by_uuid(str(updated_user.uuid), include_history=True) + if user is None: + raise ValueError("Не удалось получить локальный кэш пользователя после привязки.") + return user + + async def sync_user_by_identifier( + self, + *, + identifier: str, + include_history: bool = True, + ) -> CachedUserView: + resolved = await self._remnawave_client.resolve_user(identifier) + remote_user = await self._remnawave_client.get_user_by_uuid(str(resolved.uuid)) + history = None + + if include_history and self._settings.sync_subscription_history: + history = await self._remnawave_client.get_user_subscription_history(str(resolved.uuid)) + + async with self._session_factory() as session: + await self._upsert_remnawave_user(session, remote_user, owner=None) + if history is not None: + await self._upsert_history(session, str(resolved.uuid), history) + await session.commit() + + user = await self.get_cached_user_by_uuid(str(resolved.uuid), include_history=include_history) + if user is None: + raise ValueError("Не удалось получить локальный кэш пользователя после синхронизации.") + return user + + async def sync_all_users(self, batch_size: int) -> int: + start = 0 + total_synced = 0 + + while True: + page = await self._remnawave_client.get_all_users(start=start, size=batch_size) + if not page.users: + break + + async with self._session_factory() as session: + for remote_user in page.users: + await self._upsert_remnawave_user(session, remote_user, owner=None) + total_synced += 1 + await session.commit() + + start += len(page.users) + if start >= page.total: + break + + return total_synced + + async def get_cached_users_for_telegram(self, telegram_id: int) -> list[CachedUserView]: + async with self._session_factory() as session: + rows = await session.execute( + select(RemnawaveUser, InternalSquad.name) + .outerjoin( + RemnawaveUserInternalSquad, + RemnawaveUserInternalSquad.user_id == RemnawaveUser.id, + ) + .outerjoin( + InternalSquad, + InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid, + ) + .where(RemnawaveUser.telegram_id == telegram_id) + .order_by(RemnawaveUser.username.asc()) + ) + return self._build_views(rows.all()) + + async def get_cached_user_by_uuid( + self, + user_uuid: str, + *, + include_history: bool = False, + ) -> CachedUserView | None: + async with self._session_factory() as session: + rows = await session.execute( + select(RemnawaveUser, InternalSquad.name) + .outerjoin( + RemnawaveUserInternalSquad, + RemnawaveUserInternalSquad.user_id == RemnawaveUser.id, + ) + .outerjoin( + InternalSquad, + InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid, + ) + .where(RemnawaveUser.rw_uuid == user_uuid) + ) + views = self._build_views(rows.all()) + if not views: + return None + + view = views[0] + if include_history: + history_rows = await session.execute( + select(SubscriptionRequestLog) + .where(SubscriptionRequestLog.user_uuid == user_uuid) + .order_by(SubscriptionRequestLog.request_at.desc()) + .limit(5) + ) + view.recent_requests = list(history_rows.scalars().all()) + return view + + async def _upsert_telegram_user( + self, + session: AsyncSession, + *, + telegram_id: int, + username: str | None, + first_name: str | None, + last_name: str | None, + language_code: str | None, + ) -> TelegramUser: + record = await session.scalar( + select(TelegramUser).where(TelegramUser.telegram_id == telegram_id) + ) + + if record is None: + record = TelegramUser(telegram_id=telegram_id) + session.add(record) + + record.username = username + record.first_name = first_name + record.last_name = last_name + record.language_code = language_code + record.is_admin = self._settings.is_admin(telegram_id) + record.last_seen_at = utcnow() + record.updated_at = utcnow() + + await session.flush() + return record + + async def _sync_remote_users_for_telegram( + self, + session: AsyncSession, + *, + telegram_id: int, + remote_users: list[RemoteRemnawaveUser], + owner: TelegramUser | None, + ) -> None: + active_uuids = {str(remote_user.uuid) for remote_user in remote_users} + for remote_user in remote_users: + await self._upsert_remnawave_user(session, remote_user, owner) + + filters = [RemnawaveUser.telegram_id == telegram_id] + if owner is not None: + filters.append(RemnawaveUser.owner_telegram_user_id == owner.id) + + stale_records = await session.scalars( + select(RemnawaveUser).where(or_(*filters)) + ) + for record in stale_records: + if record.rw_uuid in active_uuids: + continue + + if record.telegram_id == telegram_id: + record.telegram_id = None + if owner is not None and record.owner_telegram_user_id == owner.id: + record.owner_telegram_user_id = None + record.synced_at = utcnow() + + async def _upsert_remnawave_user( + self, + session: AsyncSession, + remote_user: RemoteRemnawaveUser, + owner: TelegramUser | None, + ) -> RemnawaveUser: + record = await session.scalar( + select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid)) + ) + + if record is None: + record = RemnawaveUser(rw_uuid=str(remote_user.uuid)) + session.add(record) + + owner_id = owner.id if owner is not None else await self._find_owner_id(session, remote_user.telegram_id) + + record.owner_telegram_user_id = owner_id + record.rw_id = remote_user.id + record.short_uuid = remote_user.short_uuid + record.username = remote_user.username + record.status = remote_user.status + record.traffic_limit_bytes = remote_user.traffic_limit_bytes + record.used_traffic_bytes = remote_user.user_traffic.used_traffic_bytes + record.lifetime_used_traffic_bytes = remote_user.user_traffic.lifetime_used_traffic_bytes + record.expire_at = self._normalize_datetime(remote_user.expire_at) + record.telegram_id = remote_user.telegram_id + record.email = remote_user.email + record.description = remote_user.description + record.tag = remote_user.tag + record.hwid_device_limit = remote_user.hwid_device_limit + record.external_squad_uuid = self._maybe_str(remote_user.external_squad_uuid) + record.trojan_password = remote_user.trojan_password + record.vless_uuid = str(remote_user.vless_uuid) + record.ss_password = remote_user.ss_password + record.last_triggered_threshold = remote_user.last_triggered_threshold + record.sub_revoked_at = self._normalize_datetime(remote_user.sub_revoked_at) + record.sub_last_user_agent = remote_user.sub_last_user_agent + record.sub_last_opened_at = self._normalize_datetime(remote_user.sub_last_opened_at) + record.last_traffic_reset_at = self._normalize_datetime(remote_user.last_traffic_reset_at) + record.subscription_url = remote_user.subscription_url + record.online_at = self._normalize_datetime(remote_user.user_traffic.online_at) + record.first_connected_at = self._normalize_datetime(remote_user.user_traffic.first_connected_at) + record.last_connected_node_uuid = self._maybe_str(remote_user.user_traffic.last_connected_node_uuid) + record.remote_created_at = self._normalize_datetime(remote_user.created_at) + record.remote_updated_at = self._normalize_datetime(remote_user.updated_at) + record.synced_at = utcnow() + + await session.flush() + await session.execute( + delete(RemnawaveUserInternalSquad).where( + RemnawaveUserInternalSquad.user_id == record.id + ) + ) + + for squad in remote_user.active_internal_squads: + squad_uuid = str(squad.uuid) + squad_record = await session.get(InternalSquad, squad_uuid) + if squad_record is None: + squad_record = InternalSquad(uuid=squad_uuid, name=squad.name, synced_at=utcnow()) + session.add(squad_record) + else: + squad_record.name = squad.name + squad_record.synced_at = utcnow() + + session.add( + RemnawaveUserInternalSquad( + user_id=record.id, + squad_uuid=squad_uuid, + ) + ) + + await session.flush() + return record + + async def _upsert_history( + self, + session: AsyncSession, + user_uuid: str, + history: SubscriptionRequestHistory, + ) -> None: + for item in history.records: + existing = await session.scalar( + select(SubscriptionRequestLog).where( + SubscriptionRequestLog.remote_id == item.id + ) + ) + if existing is None: + existing = SubscriptionRequestLog(remote_id=item.id, user_uuid=user_uuid) + session.add(existing) + + existing.user_uuid = user_uuid + existing.request_at = self._normalize_datetime(item.request_at) + existing.request_ip = item.request_ip + existing.user_agent = item.user_agent + existing.synced_at = utcnow() + + async def _upsert_referral_invite( + self, + session: AsyncSession, + *, + invited: TelegramUser, + referred_by_telegram_id: int | None, + ) -> bool: + if referred_by_telegram_id is None or referred_by_telegram_id == invited.telegram_id: + return False + + existing = await session.scalar( + select(ReferralInvite).where( + ReferralInvite.invited_telegram_id == invited.telegram_id + ) + ) + if existing is not None: + return False + + display_name = " ".join( + part for part in [invited.first_name, invited.last_name] if part + ).strip() or invited.username or str(invited.telegram_id) + + session.add( + ReferralInvite( + inviter_telegram_id=referred_by_telegram_id, + invited_telegram_id=invited.telegram_id, + invited_username=invited.username, + invited_display_name=display_name, + ) + ) + return True + + async def _get_or_create_referral_code( + self, + session: AsyncSession, + *, + telegram_id: int, + ) -> str: + existing = await session.scalar( + select(ReferralCode).where(ReferralCode.telegram_id == telegram_id) + ) + if existing is not None: + return existing.code + + alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + while True: + code = "".join(secrets.choice(alphabet) for _ in range(8)) + duplicate = await session.scalar( + select(ReferralCode.id).where(ReferralCode.code == code) + ) + if duplicate is None: + session.add(ReferralCode(telegram_id=telegram_id, code=code)) + await session.flush() + return code + + async def _find_inviter_by_referral_code( + self, + session: AsyncSession, + referred_by_referral_code: str | None, + ) -> int | None: + raw_code = (referred_by_referral_code or "").strip().upper() + if not raw_code: + return None + + return await session.scalar( + select(ReferralCode.telegram_id).where(ReferralCode.code == raw_code) + ) + + async def _find_owner_id(self, session: AsyncSession, telegram_id: int | None) -> int | None: + if telegram_id is None: + return None + + return await session.scalar( + select(TelegramUser.id).where(TelegramUser.telegram_id == telegram_id) + ) + + @staticmethod + def _normalize_datetime(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value + return value.astimezone(timezone.utc).replace(tzinfo=None) + + @staticmethod + def _maybe_str(value: object | None) -> str | None: + return str(value) if value is not None else None + + @staticmethod + def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]: + view_map: dict[int, CachedUserView] = {} + for user, squad_name in rows: + view = view_map.setdefault(user.id, CachedUserView(record=user)) + if squad_name and squad_name not in view.internal_squads: + view.internal_squads.append(squad_name) + return list(view_map.values()) diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e9b9a78 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +"""Utility helpers.""" diff --git a/app/utils/formatters.py b/app/utils/formatters.py new file mode 100644 index 0000000..764b47c --- /dev/null +++ b/app/utils/formatters.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import html +import math +from datetime import datetime +from typing import Any + + +def format_bytes(value: int | float | None) -> str: + if value is None or value <= 0: + return "0 B" + + units = ["B", "KB", "MB", "GB", "TB", "PB"] + size = float(value) + unit_index = 0 + + while size >= 1024 and unit_index < len(units) - 1: + size /= 1024 + unit_index += 1 + + if unit_index == 0: + return f"{int(size)} {units[unit_index]}" + return f"{size:.2f} {units[unit_index]}" + + +def format_datetime(value: datetime | None) -> str: + if value is None: + return "не указано" + return value.strftime("%d.%m.%Y %H:%M") + + +def format_traffic_limit(limit_bytes: int | None) -> str: + if limit_bytes is None or limit_bytes <= 0: + return "без лимита" + return format_bytes(limit_bytes) + + +def _format_days_word(value: int) -> str: + remainder_100 = value % 100 + remainder_10 = value % 10 + if 11 <= remainder_100 <= 14: + return "дней" + if remainder_10 == 1: + return "день" + if 2 <= remainder_10 <= 4: + return "дня" + return "дней" + + +def format_datetime_with_days_left(value: datetime | None) -> str: + if value is None: + return "не указано" + + now = datetime.now(value.tzinfo) if value.tzinfo is not None else datetime.now() + seconds_left = (value - now).total_seconds() + if seconds_left <= 0: + return f"{format_datetime(value)} (истёк)" + + days_left = max(1, math.ceil(seconds_left / 86400)) + return f"{format_datetime(value)} ({days_left} {_format_days_word(days_left)})" + + +def build_help_text(*, is_admin: bool) -> str: + lines = [ + "Команды", + "/start - открыть главное меню", + "", + "Дальше используйте кнопки внутри интерфейса бота.", + ] + return "\n".join(lines) + + +def format_payment_plan( + *, + title: str, + description: str, + price_stars: int, + duration_days: int, + traffic_limit_bytes: int, +) -> str: + lines = [ + "Тариф", + "", + f"{html.escape(title)}", + html.escape(description), + "", + f"Срок: {duration_days} дн.", + f"Трафик: {format_traffic_limit(traffic_limit_bytes)}", + f"Цена: {price_stars} ₽", + ] + return "\n".join(lines) + + +def format_issued_access( + *, + subscription_url: str, + expire_at: datetime, + traffic_limit_bytes: int, + brand_name: str = "", + is_renewal: bool = False, +) -> str: + normalized_brand_name = html.escape(brand_name.strip()) if brand_name.strip() else "VPN" + title = ( + f"🔥 {normalized_brand_name} — подписка продлена" + if is_renewal + else f"🔥 {normalized_brand_name} — доступ готов" + ) + status_text = ( + "Подписка продлена. Откройте её кнопкой ниже " + "или импортируйте ссылку вручную в ваш VPN-клиент." + if is_renewal + else "Подписка активирована. Откройте её кнопкой ниже " + "или импортируйте ссылку вручную в ваш VPN-клиент." + ) + lines = [ + title, + "", + f"
{status_text}
", + "", + "🔗 Ключ / подписка", + html.escape(subscription_url), + "", + ( + "
" + f"⏳ Действует до: {format_datetime_with_days_left(expire_at)}\n" + f"📶 Трафик: {format_traffic_limit(traffic_limit_bytes)}" + "
" + ), + "", + "📲 Что делать дальше", + "1. Нажмите кнопку «Открыть подписку» ниже или откройте ссылку выше.", + "2. Импортируйте подписку или ключ в приложение VPN-клиента.", + "3. Управлять доступом и продлевать его можно в панели бота.", + "", + "Если ссылка не открывается автоматически, скопируйте её в клиент вручную.", + ] + return "\n".join(lines) + + +def format_user_card( + *, + user: Any, + squad_names: list[str], + recent_requests: list[Any] | None = None, +) -> str: + lines = [ + f"{html.escape(user.username)} {html.escape(user.status)}", + f"UUID: {html.escape(user.rw_uuid)}", + f"Short UUID: {html.escape(user.short_uuid)}", + f"Telegram ID: {user.telegram_id if user.telegram_id is not None else '-'}", + f"Истекает: {format_datetime(user.expire_at)}", + ( + "Трафик: " + f"{format_bytes(user.used_traffic_bytes)} / " + f"{format_traffic_limit(user.traffic_limit_bytes)}" + ), + f"За все время: {format_bytes(user.lifetime_used_traffic_bytes)}", + f"Последний UA: {html.escape(user.sub_last_user_agent or 'неизвестно')}", + f"Последнее открытие подписки: {format_datetime(user.sub_last_opened_at)}", + f"Подписка: {html.escape(user.subscription_url)}", + ] + + if user.email: + lines.append(f"Email: {html.escape(user.email)}") + if user.tag: + lines.append(f"Tag: {html.escape(user.tag)}") + if user.description: + lines.append(f"Описание: {html.escape(user.description)}") + if squad_names: + lines.append(f"Squads: {html.escape(', '.join(sorted(squad_names)))}") + + if recent_requests: + lines.append("") + lines.append("Последние запросы подписки") + for request in recent_requests: + lines.append( + f"- {format_datetime(request.request_at)} | " + f"IP: {html.escape(request.request_ip or '-')} | " + f"UA: {html.escape((request.user_agent or '-')[:80])}" + ) + + return "\n".join(lines) diff --git a/assets/Thumbs.db b/assets/Thumbs.db new file mode 100644 index 0000000..a895555 Binary files /dev/null and b/assets/Thumbs.db differ diff --git a/assets/main-white-backup.png b/assets/main-white-backup.png new file mode 100644 index 0000000..dbec9e9 Binary files /dev/null and b/assets/main-white-backup.png differ diff --git a/assets/main.png b/assets/main.png new file mode 100644 index 0000000..1fefb07 Binary files /dev/null and b/assets/main.png differ diff --git a/config/__init__.py b/config/__init__.py deleted file mode 100644 index f572be1..0000000 --- a/config/__init__.py +++ /dev/null @@ -1,59 +0,0 @@ -import os -from pathlib import Path -from dotenv import load_dotenv - -# Загрузка переменных окружения -load_dotenv() - - -class Config: - """Класс для управления конфигурацией""" - - def __init__(self): - # Telegram - self.bot_token = os.getenv("BOT_TOKEN", "") - - # Database - self.db_host = os.getenv("DB_HOST", "localhost") - self.db_user = os.getenv("DB_USER", "root") - self.db_password = os.getenv("DB_PASSWORD", "") - self.db_name = os.getenv("DB_NAME", "botyobshik") - - # Support - self.support_username = os.getenv("SUPPORT_USERNAME", "@support_username") - - # Payment - self.payment_channel_id = os.getenv("PAYMENT_CHANNEL_ID", "") - self.payment_thread_id = os.getenv("PAYMENT_THREAD_ID", "") - self.payment_ticket_channel_id = os.getenv("PAYMENT_TICKET_CHANNEL_ID", "") - self.payment_ticket_thread_id = os.getenv("PAYMENT_TICKET_THREAD_ID", "") - self.payment_phone = os.getenv("PAYMENT_PHONE", "+7 904 788 77 35") - - # Admin - admin_ids_str = os.getenv("ADMIN_IDS", "") - self.admin_ids = [int(x.strip()) for x in admin_ids_str.split(",") if x.strip().isdigit()] - - # Remnawave API - self.remwave_api_url = os.getenv("REMWAVE_API_URL", "") - self.remwave_api_key = os.getenv("REMWAVE_API_KEY", "") - - @property - def db_config(self) -> dict: - """Конфигурация подключения к БД""" - return { - "host": self.db_host, - "user": self.db_user, - "password": self.db_password, - } - - @property - def db_config_with_name(self) -> dict: - """Конфигурация подключения к БД с именем базы""" - return { - **self.db_config, - "database": self.db_name - } - - -# Глобальный экземпляр конфигурации -config = Config() diff --git a/core/__init__.py b/core/__init__.py deleted file mode 100644 index 62cf529..0000000 --- a/core/__init__.py +++ /dev/null @@ -1,518 +0,0 @@ -import mysql.connector -from mysql.connector import Error -from datetime import datetime, timedelta -from typing import Optional -import asyncio -from functools import wraps - -from config import config - - -def run_sync(func): - """Декоратор для запуска синхронных функций в async""" - @wraps(func) - async def wrapper(*args, **kwargs): - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, func, *args, **kwargs) - return wrapper - - -class Database: - """Класс для работы с базой данных""" - - def __init__(self): - self.db_config = config.db_config - self.db_config_with_name = config.db_config_with_name - - def get_connection(self, with_db: bool = True): - """Подключение к MySQL""" - db_config = self.db_config_with_name if with_db else self.db_config - try: - connection = mysql.connector.connect(**db_config) - return connection - except Error as e: - print(f"Ошибка подключения к БД: {e}") - return None - - async def create_database_if_not_exists(self) -> bool: - """Создание базы данных если она не существует""" - connection = self.get_connection(with_db=False) - if not connection: - return False - - try: - cursor = connection.cursor() - cursor.execute( - f"CREATE DATABASE IF NOT EXISTS `{config.db_name}` " - "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" - ) - connection.commit() - cursor.close() - connection.close() - print(f"База данных '{config.db_name}' проверена/создана") - return True - except Error as e: - print(f"Ошибка создания БД: {e}") - return False - - async def create_tables(self) -> bool: - """Создание таблиц в базе данных""" - connection = self.get_connection(with_db=True) - if not connection: - return False - - try: - cursor = connection.cursor() - - # Таблица подписок - cursor.execute(""" - CREATE TABLE IF NOT EXISTS subscriptions ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT UNIQUE NOT NULL, - username VARCHAR(255), - is_active BOOLEAN DEFAULT FALSE, - subscription_start DATETIME, - subscription_end DATETIME, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) - """) - - # Таблица языков пользователей - cursor.execute(""" - CREATE TABLE IF NOT EXISTS user_languages ( - user_id BIGINT PRIMARY KEY, - language_code VARCHAR(10) DEFAULT 'ru', - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) - """) - - # Таблица тикетов - cursor.execute(""" - CREATE TABLE IF NOT EXISTS tickets ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - username VARCHAR(255), - message TEXT NOT NULL, - status VARCHAR(20) DEFAULT 'open', - admin_response TEXT, - closed_by VARCHAR(255), - closed_at DATETIME, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_user_id (user_id), - INDEX idx_status (status) - ) - """) - - # Индексы - cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_id ON subscriptions(user_id)") - - connection.commit() - cursor.close() - connection.close() - print("Таблицы созданы/проверены") - return True - except Error as e: - print(f"Ошибка создания таблиц: {e}") - return False - - async def initialize(self): - """Инициализация БД: создание БД и таблиц""" - await self.create_database_if_not_exists() - await self.create_tables() - - @run_sync - def get_user_subscription(self, user_id: int) -> bool: - """Проверка статуса подписки пользователя""" - connection = self.get_connection() - if not connection: - return False - - try: - cursor = connection.cursor(dictionary=True) - cursor.execute( - "SELECT is_active FROM subscriptions WHERE user_id = %s", - (user_id,) - ) - result = cursor.fetchone() - cursor.close() - connection.close() - - return result['is_active'] if result else False - except Error as e: - print(f"Ошибка БД: {e}") - return False - - @run_sync - def create_user(self, user_id: int, username: str) -> bool: - """Создание нового пользователя в базе""" - connection = self.get_connection() - if not connection: - return False - - try: - cursor = connection.cursor() - cursor.execute( - """INSERT INTO subscriptions (user_id, username, is_active) - VALUES (%s, %s, FALSE) - ON DUPLICATE KEY UPDATE username = %s""", - (user_id, username, username) - ) - connection.commit() - cursor.close() - connection.close() - return True - except Error as e: - print(f"Ошибка БД: {e}") - return False - - @run_sync - def activate_subscription(self, user_id: int, days: int = 30) -> bool: - """Активация подписки на указанное количество дней""" - connection = self.get_connection() - if not connection: - return False - - try: - cursor = connection.cursor() - now = datetime.now() - end_date = now + timedelta(days=days) - - cursor.execute( - """INSERT INTO subscriptions (user_id, is_active, subscription_start, subscription_end) - VALUES (%s, TRUE, %s, %s) - ON DUPLICATE KEY UPDATE - is_active = TRUE, - subscription_start = %s, - subscription_end = %s""", - (user_id, now, end_date, now, end_date) - ) - connection.commit() - cursor.close() - connection.close() - return True - except Error as e: - print(f"Ошибка БД: {e}") - return False - - @run_sync - def deactivate_subscription(self, user_id: int) -> bool: - """Деактивация подписки""" - connection = self.get_connection() - if not connection: - return False - - try: - cursor = connection.cursor() - cursor.execute( - "UPDATE subscriptions SET is_active = FALSE WHERE user_id = %s", - (user_id,) - ) - connection.commit() - cursor.close() - connection.close() - return True - except Error as e: - print(f"Ошибка БД: {e}") - return False - - @run_sync - def get_subscription_end_date(self, user_id: int) -> Optional[datetime]: - """Получение даты окончания подписки""" - connection = self.get_connection() - if not connection: - return None - - try: - cursor = connection.cursor(dictionary=True) - cursor.execute( - "SELECT subscription_end FROM subscriptions WHERE user_id = %s", - (user_id,) - ) - result = cursor.fetchone() - cursor.close() - connection.close() - - return result['subscription_end'] if result and result['subscription_end'] else None - except Error as e: - print(f"Ошибка БД: {e}") - return None - - @run_sync - def get_user_language(self, user_id: int) -> str: - """Получение языка пользователя""" - connection = self.get_connection() - if not connection: - return "ru" - - try: - cursor = connection.cursor(dictionary=True) - cursor.execute( - "SELECT language_code FROM user_languages WHERE user_id = %s", - (user_id,) - ) - result = cursor.fetchone() - cursor.close() - connection.close() - - return result['language_code'] if result else "ru" - except Error as e: - print(f"Ошибка БД: {e}") - return "ru" - - @run_sync - def set_user_language(self, user_id: int, language: str) -> bool: - """Установка языка пользователя""" - connection = self.get_connection() - if not connection: - return False - - try: - cursor = connection.cursor() - cursor.execute( - """INSERT INTO user_languages (user_id, language_code) - VALUES (%s, %s) - ON DUPLICATE KEY UPDATE language_code = %s""", - (user_id, language, language) - ) - connection.commit() - cursor.close() - connection.close() - return True - except Error as e: - print(f"Ошибка БД: {e}") - return False - - @run_sync - def create_ticket(self, user_id: int, username: str, message: str) -> Optional[int]: - """Создание тикета""" - connection = self.get_connection() - if not connection: - return None - - try: - cursor = connection.cursor() - cursor.execute( - """INSERT INTO tickets (user_id, username, message, status) - VALUES (%s, %s, %s, 'open')""", - (user_id, username, message) - ) - connection.commit() - ticket_id = cursor.lastrowid - cursor.close() - connection.close() - return ticket_id - except Error as e: - print(f"Ошибка БД: {e}") - return None - - @run_sync - def get_user_tickets(self, user_id: int) -> list: - """Получение всех тикетов пользователя""" - connection = self.get_connection() - if not connection: - return [] - - try: - cursor = connection.cursor(dictionary=True) - cursor.execute( - """SELECT id, message, status, admin_response, created_at - FROM tickets - WHERE user_id = %s - ORDER BY created_at DESC""", - (user_id,) - ) - tickets = cursor.fetchall() - cursor.close() - connection.close() - return tickets - except Error as e: - print(f"Ошибка БД: {e}") - return [] - - @run_sync - def get_ticket(self, ticket_id: int) -> Optional[dict]: - """Получение информации о тикете""" - connection = self.get_connection() - if not connection: - return None - - try: - cursor = connection.cursor(dictionary=True) - cursor.execute( - "SELECT * FROM tickets WHERE id = %s", - (ticket_id,) - ) - ticket = cursor.fetchone() - cursor.close() - connection.close() - return ticket - except Error as e: - print(f"Ошибка БД: {e}") - return None - - @run_sync - def update_ticket_response(self, ticket_id: int, admin_response: str, status: str = 'answered') -> bool: - """Обновление ответа администратора в тикете""" - connection = self.get_connection() - if not connection: - return False - - try: - cursor = connection.cursor() - cursor.execute( - """UPDATE tickets - SET admin_response = %s, status = %s - WHERE id = %s""", - (admin_response, status, ticket_id) - ) - connection.commit() - cursor.close() - connection.close() - return True - except Error as e: - print(f"Ошибка БД: {e}") - return False - - @run_sync - def close_ticket(self, ticket_id, closed_by): - """Закрытие тикета""" - connection = self.get_connection() - if not connection: - return False - - try: - cursor = connection.cursor() - cursor.execute( - """UPDATE tickets - SET status = 'closed', closed_by = %s, closed_at = %s - WHERE id = %s""", - (closed_by, datetime.now(), ticket_id) - ) - connection.commit() - cursor.close() - connection.close() - return True - except Error as e: - print(f"Ошибка БД: {e}") - return False - - @run_sync - def append_ticket_message(self, ticket_id: int, user_message: str) -> bool: - """Добавление сообщения к тикету""" - connection = self.get_connection() - if not connection: - return False - - try: - cursor = connection.cursor() - # Получаем текущее сообщение - cursor.execute("SELECT message FROM tickets WHERE id = %s", (ticket_id,)) - result = cursor.fetchone() - if result: - current_message = result[0] - new_message = f"{current_message}\n\n➕ Дополнение:\n{user_message}" - cursor.execute( - "UPDATE tickets SET message = %s WHERE id = %s", - (new_message, ticket_id) - ) - connection.commit() - cursor.close() - connection.close() - return True - return False - except Error as e: - print(f"Ошибка БД: {e}") - return False - - - @run_sync - def get_all_users_paginated(self, page, per_page=10): - """ - Получение всех пользователей с пагинацией - - Args: - page: Номер страницы (начиная с 1) - per_page: Количество пользователей на странице - - Returns: - dict: {'users': [...], 'total': int, 'total_pages': int, 'current_page': int} - """ - connection = self.get_connection() - if not connection: - return {'users': [], 'total': 0, 'total_pages': 0, 'current_page': page} - - try: - cursor = connection.cursor(dictionary=True) - - # Получаем общее количество пользователей - cursor.execute("SELECT COUNT(*) as count FROM subscriptions") - total = cursor.fetchone()['count'] - - # Вычисляем общее количество страниц - total_pages = (total + per_page - 1) // per_page if total > 0 else 0 - - # Ограничиваем page в допустимых пределах - page = max(1, min(page, total_pages if total_pages > 0 else 1)) - - # Получаем пользователей с пагинацией - offset = (page - 1) * per_page - cursor.execute( - """SELECT id, user_id, username, is_active, subscription_start, subscription_end, created_at - FROM subscriptions - ORDER BY created_at DESC - LIMIT %s OFFSET %s""", - (per_page, offset) - ) - users = cursor.fetchall() - - cursor.close() - connection.close() - - return { - 'users': users, - 'total': total, - 'total_pages': total_pages, - 'current_page': page - } - except Error as e: - print(f"Ошибка БД: {e}") - return {'users': [], 'total': 0, 'total_pages': 0, 'current_page': page} - - @run_sync - def get_users_count(self): - """ - Получение количества пользователей по статусам - - Returns: - dict: {'total': int, 'active': int, 'inactive': int} - """ - connection = self.get_connection() - if not connection: - return {'total': 0, 'active': 0, 'inactive': 0} - - try: - cursor = connection.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) as count FROM subscriptions") - total = cursor.fetchone()['count'] - - cursor.execute("SELECT COUNT(*) as count FROM subscriptions WHERE is_active = TRUE") - active = cursor.fetchone()['count'] - - cursor.close() - connection.close() - - return { - 'total': total, - 'active': active, - 'inactive': total - active - } - except Error as e: - print(f"Ошибка БД: {e}") - return {'total': 0, 'active': 0, 'inactive': 0} - - -# Глобальный экземпляр базы данных -db = Database() diff --git a/core/remnawave.py b/core/remnawave.py deleted file mode 100644 index 50917cc..0000000 --- a/core/remnawave.py +++ /dev/null @@ -1,439 +0,0 @@ -import aiohttp -from config import config - - -class RemnawaveAPI: - """Клиент для работы с Remnawave API""" - - def __init__(self): - self.api_url = config.remwave_api_url - self.api_key = config.remwave_api_key - self.headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json" - } - - async def create_subscription(self, user_id: int, days: int, tariff: str) -> dict: - """ - Создание пользователя (подписки) в Remnawave - - Args: - user_id: Telegram user ID - days: Количество дней подписки - tariff: Название тарифа - - Returns: - dict: Результат создания подписки - """ - # Эндпоинт для создания пользователя - url = f"{self.api_url}/api/users" - - # Генерируем случайный username и пароль - import random - import string - random_username = f"subs{user_id}" - random_password = ''.join(random.choices(string.ascii_letters + string.digits, k=12)) - - # Вычисляем дату истечения - from datetime import datetime, timedelta - expire_at = (datetime.now() + timedelta(days=days)).isoformat() - - # Данные для создания пользователя (формат Remnawave) - data = { - "username": random_username, - "password": random_password, - "telegramId": user_id, # Число, не строка - "expireAt": expire_at # ISO формат даты - } - - async with aiohttp.ClientSession() as session: - try: - async with session.post(url, json=data, headers=self.headers) as response: - result = await response.json() - - if response.status in [200, 201]: - print(f"✅ Подписка создана для user {user_id}") - print(f"📋 Ответ API: {result}") - - # Получаем ссылку на подключение - # API возвращает в формате: {'response': {'subscriptionUrl': '...'}} - subscription_url = None - if isinstance(result, dict): - # Проверяем response.subscriptionUrl (основной вариант) - response_data = result.get("response", {}) - if isinstance(response_data, dict): - subscription_url = response_data.get("subscriptionUrl") - - # Если не нашли, проверяем другие варианты - if not subscription_url: - subscription_url = ( - result.get("subscriptionUrl") or - result.get("subscription") or - result.get("link") or - result.get("url") or - result.get("data", {}).get("subscriptionUrl") or - result.get("data", {}).get("subscription") or - result.get("data", {}).get("link") - ) - - print(f"🔗 Subscription URL: {subscription_url}") - - return { - "success": True, - "data": result, - "subscription_url": subscription_url, - "username": random_username - } - else: - print(f"❌ Ошибка создания подписки: {response.status} - {result}") - return {"success": False, "error": result, "status": response.status} - except aiohttp.ClientError as e: - print(f"❌ Ошибка подключения к Remnawave API: {e}") - return {"success": False, "error": str(e)} - - async def get_user_subscription(self, user_id: int) -> dict: - """ - Получение информации о подписке пользователя - - Args: - user_id: Telegram user ID - - Returns: - dict: Информация о подписке - """ - url = f"{self.api_url}/api/users/telegram/{user_id}" - - async with aiohttp.ClientSession() as session: - try: - async with session.get(url, headers=self.headers) as response: - result = await response.json() - - if response.status == 200: - return {"success": True, "data": result} - else: - return {"success": False, "error": result, "status": response.status} - except aiohttp.ClientError as e: - return {"success": False, "error": str(e)} - - async def extend_subscription(self, user_id: int, days: int) -> dict: - """ - Продление подписки - - Args: - user_id: Telegram user ID - days: Количество дней для продления - - Returns: - dict: Результат продления - """ - url = f"{self.api_url}/api/users/telegram/{user_id}/extend" - - data = { - "durationDays": days - } - - async with aiohttp.ClientSession() as session: - try: - async with session.post(url, json=data, headers=self.headers) as response: - result = await response.json() - - if response.status == 200: - print(f"✅ Подписка продлена для user {user_id} на {days} дней") - return {"success": True, "data": result} - else: - print(f"❌ Ошибка продления подписки: {response.status} - {result}") - return {"success": False, "error": result, "status": response.status} - except aiohttp.ClientError as e: - print(f"❌ Ошибка подключения к Remnawave API: {e}") - return {"success": False, "error": str(e)} - - async def get_all_users(self, page: int = 0, size: int = 500) -> dict: - """ - Получение всех пользователей из Remnawave с пагинацией - - Args: - page: Номер страницы (начиная с 0) - size: Размер страницы (макс 500) - - Returns: - dict: Список пользователей - """ - url = f"{self.api_url}/api/users" - params = {"start": page, "size": min(size, 500)} - - async with aiohttp.ClientSession() as session: - try: - async with session.get(url, headers=self.headers, params=params) as response: - result = await response.json() - print(f"📋 Remnawave API ответ: status={response.status}, keys={list(result.keys()) if isinstance(result, dict) else type(result)}") - - if response.status == 200: - # Remnawave возвращает {'response': {'content': [...], 'total': N}} - users_list = [] - - if isinstance(result, dict): - # Пробуем разные варианты извлечения списка - if 'response' in result and isinstance(result['response'], dict): - response_data = result['response'] - if 'content' in response_data and isinstance(response_data['content'], list): - users_list = response_data['content'] - print(f"✅ Получено {len(users_list)} пользователей (response.content)") - elif 'users' in response_data and isinstance(response_data['users'], list): - users_list = response_data['users'] - print(f"✅ Получено {len(users_list)} пользователей (response.users)") - elif 'content' in result and isinstance(result['content'], list): - users_list = result['content'] - print(f"✅ Получено {len(users_list)} пользователей (content)") - elif 'data' in result and isinstance(result['data'], list): - users_list = result['data'] - print(f"✅ Получено {len(users_list)} пользователей (data)") - elif 'users' in result and isinstance(result['users'], list): - users_list = result['users'] - print(f"✅ Получено {len(users_list)} пользователей (users)") - - if users_list: - return {"success": True, "data": {"content": users_list, "total": len(users_list)}} - - print(f"⚠️ Не найдено пользователей в ответе") - return {"success": True, "data": {"content": [], "total": 0}} - else: - print(f"❌ Ошибка получения пользователей: {response.status} - {result}") - return {"success": False, "error": result, "status": response.status} - except aiohttp.ClientError as e: - print(f"❌ Ошибка подключения к Remnawave API: {e}") - return {"success": False, "error": str(e)} - - async def sync_user(self, user_id: int, username: str, days: int = 30) -> dict: - """ - Синхронизация пользователя (создание если нет в Remnawave) - - Args: - user_id: Telegram user ID - username: Имя пользователя - days: Количество дней - - Returns: - dict: Результат синхронизации - """ - # Сначала пробуем найти пользователя по Telegram ID через API - print(f"🔍 Поиск пользователя по Telegram ID: {user_id}") - check_result = await self.get_user_subscription(user_id) - - if check_result.get("success") and check_result.get("data"): - # Пользователь уже есть в Remnawave с этим telegramId - print(f"✅ Пользователь {user_id} уже найден в Remnawave по Telegram ID") - return {"success": True, "exists": True, "data": check_result.get("data")} - - # Если не нашли по telegramId, пробуем найти по username (subs{user_id}) - remnawave_username = f"subs{user_id}" - print(f"🔍 Не найдено по Telegram ID, поиск по username: {remnawave_username}") - - # Получаем всех пользователей и ищем по username - all_users_result = await self.get_all_users(page=0, size=500) - - if all_users_result.get("success"): - users_data = all_users_result.get("data", {}) - users_list = users_data.get("content", []) - - print(f"📋 Получено {len(users_list)} пользователей из Remnawave") - - # Логируем первые несколько для отладки - if users_list and len(users_list) > 0: - first_users = [(u.get("username"), u.get("telegramId")) for u in users_list[:5] if isinstance(u, dict)] - print(f"📋 Первые пользователи: {first_users}") - - # Ищем по username - for user in users_list: - if isinstance(user, dict) and user.get("username") == remnawave_username: - user_uuid = user.get("uuid") - print(f"✅ Найден пользователь {remnawave_username} с UUID {user_uuid}") - if user_uuid: - update_result = await self._update_user_telegram_id(user_uuid, user_id) - if update_result.get("success"): - print(f"✅ Обновлён telegramId для user {user_id}") - return {"success": True, "exists": True, "updated": True, "data": update_result.get("data")} - else: - return update_result - - # Создаём нового пользователя - print(f"➕ Создание нового пользователя {remnawave_username}") - create_result = await self.create_subscription(user_id, days, remnawave_username) - - # Если ошибка "username already exists" — пользователь уже есть, это нормально - if not create_result.get("success"): - error_data = create_result.get("error", {}) - if isinstance(error_data, dict) and error_data.get("message") == "User username already exists": - print(f"ℹ️ Пользователь {remnawave_username} уже существует в Remnawave (не удалось обновить telegramId)") - # Возвращаем успех с флагом exists - return {"success": True, "exists": True, "updated": False, "data": None} - else: - print(f"❌ Ошибка создания: {error_data}") - - return create_result - - async def _find_and_update_by_username(self, username: str, telegram_id: int) -> dict: - """ - Поиск пользователя по username и обновление telegramId - - Args: - username: Имя пользователя в Remnawave - telegram_id: Telegram user ID - - Returns: - dict: Результат - """ - all_users_result = await self.get_all_users(page=0, size=500) - - if not all_users_result.get("success"): - return all_users_result - - users_data = all_users_result.get("data", {}) - users_list = users_data.get("content", []) if isinstance(users_data, dict) else (users_data if isinstance(users_data, list) else []) - - for user in users_list: - if user.get("username") == username: - user_uuid = user.get("uuid") - if user_uuid: - return await self._update_user_telegram_id(user_uuid, telegram_id) - - return {"success": False, "error": "User not found"} - - async def _update_user_telegram_id(self, user_uuid: str, telegram_id: int) -> dict: - """ - Обновление telegramId у существующего пользователя - - Args: - user_uuid: UUID пользователя в Remnawave - telegram_id: Telegram user ID - - Returns: - dict: Результат обновления - """ - # Пробуем несколько вариантов обновления - urls_to_try = [ - (f"{self.api_url}/api/users/{user_uuid}/telegram", "PATCH"), - (f"{self.api_url}/api/users/{user_uuid}", "PUT"), - (f"{self.api_url}/api/users/{user_uuid}/telegram-id", "POST"), - ] - - for url, method in urls_to_try: - async with aiohttp.ClientSession() as session: - try: - data = {"telegramId": telegram_id} - - if method == "PATCH": - async with session.patch(url, json=data, headers=self.headers) as response: - result = await response.json() - if response.status in [200, 204]: - print(f"✅ Обновлён telegramId для user {telegram_id} (PATCH)") - return {"success": True, "data": result} - elif method == "PUT": - async with session.put(url, json=data, headers=self.headers) as response: - result = await response.json() - if response.status in [200, 204]: - print(f"✅ Обновлён telegramId для user {telegram_id} (PUT)") - return {"success": True, "data": result} - elif method == "POST": - async with session.post(url, json=data, headers=self.headers) as response: - result = await response.json() - if response.status in [200, 201]: - print(f"✅ Обновлён telegramId для user {telegram_id} (POST)") - return {"success": True, "data": result} - except aiohttp.ClientError as e: - continue - - print(f"❌ Не удалось обновить telegramId - API не поддерживает обновление") - return {"success": False, "error": "API does not support user update"} - - async def sync_all_users(self) -> dict: - """ - Синхронизация всех пользователей: Remnawave → Локальная БД - - Returns: - dict: Результаты синхронизации - """ - from core import db as local_db - - # Получаем всех пользователей из Remnawave - print("🔄 Получение пользователей из Remnawave...") - remnawave_result = await self.get_all_users(page=0, size=500) - - if not remnawave_result.get("success"): - return {"success": False, "error": "Не удалось получить пользователей из Remnawave"} - - users_data = remnawave_result.get("data", {}) - users_list = users_data.get("content", []) - - print(f"📋 Получено {len(users_list)} пользователей из Remnawave") - - total = len(users_list) - imported = 0 - updated = 0 - errors = 0 - error_details = [] - - connection = local_db.get_connection() - cursor = connection.cursor() - - for user in users_list: - if not isinstance(user, dict): - continue - - user_id = user.get("telegramId") - username = user.get("username", "") - is_active = user.get("status") == "ACTIVE" - - if not user_id: - # Нет Telegram ID - пропускаем - continue - - try: - # Проверяем, есть ли пользователь в БД - cursor.execute( - "SELECT id FROM subscriptions WHERE user_id = %s", - (user_id,) - ) - existing = cursor.fetchone() - - if existing: - # Обновляем существующего - cursor.execute( - """UPDATE subscriptions - SET username = %s, is_active = %s - WHERE user_id = %s""", - (username, is_active, user_id) - ) - updated += 1 - print(f"🔄 Обновлён пользователь {user_id} ({username})") - else: - # Создаём нового - cursor.execute( - """INSERT INTO subscriptions (user_id, username, is_active) - VALUES (%s, %s, %s)""", - (user_id, username, is_active) - ) - imported += 1 - print(f"➕ Импортирован пользователь {user_id} ({username})") - - connection.commit() - - except Exception as e: - errors += 1 - error_details.append(f"User {user_id}: {str(e)}") - print(f"❌ Ошибка импорта пользователя {user_id}: {e}") - - cursor.close() - connection.close() - - return { - "success": True, - "total": total, - "imported": imported, - "updated": updated, - "errors": errors, - "error_details": error_details[:10] - } - - -# Глобальный экземпляр API -remnawave = RemnawaveAPI() diff --git a/core/update_tickets.sql b/core/update_tickets.sql deleted file mode 100644 index 45c8f2d..0000000 --- a/core/update_tickets.sql +++ /dev/null @@ -1,38 +0,0 @@ --- Обновление таблицы tickets для добавления полей закрытия -USE botyobshik; - --- Проверяем и добавляем колонку closed_by -SET @dbname = DATABASE(); -SET @tablename = 'tickets'; -SET @columnname = 'closed_by'; -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE - (table_name = @tablename) - AND (table_schema = @dbname) - AND (column_name = @columnname) - ) > 0, - 'SELECT 1', - CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(255) AFTER admin_response') -)); -PREPARE alterIfNotExists FROM @preparedStatement; -EXECUTE alterIfNotExists; -DEALLOCATE PREPARE alterIfNotExists; - --- Проверяем и добавляем колонку closed_at -SET @columnname = 'closed_at'; -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE - (table_name = @tablename) - AND (table_schema = @dbname) - AND (column_name = @columnname) - ) > 0, - 'SELECT 1', - CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' DATETIME AFTER closed_by') -)); -PREPARE alterIfNotExists FROM @preparedStatement; -EXECUTE alterIfNotExists; -DEALLOCATE PREPARE alterIfNotExists; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b225df0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + bot: + build: . + restart: unless-stopped + env_file: + - .env + environment: + DB_HOST: db + DB_PORT: 3306 + depends_on: + db: + condition: service_healthy + + db: + image: mariadb:11.7 + restart: unless-stopped + environment: + MARIADB_DATABASE: ${DB_NAME:-telegabot} + MARIADB_USER: ${DB_USER:-telegabot} + MARIADB_PASSWORD: ${DB_PASSWORD:-telegabot_password} + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root_password} + volumes: + - mariadb_data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h localhost -p$$MARIADB_ROOT_PASSWORD --silent"] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + mariadb_data: diff --git a/handlers/__init__.py b/handlers/__init__.py deleted file mode 100644 index ca95b7e..0000000 --- a/handlers/__init__.py +++ /dev/null @@ -1,1793 +0,0 @@ -from aiogram import types, Router, F -from aiogram.filters import Command -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from core import db -from core.remnawave import remnawave -from keyboards import Keyboards -from keyboards.payment import PaymentKeyboards -from config import config -from locales import localization - -# Роутер для основного меню -main_router = Router() - -# Роутер для админ-панели -admin_router = Router() - - -# Временное хранилище для тарифов пользователей (user_id -> tariff_info) -payment_temp = {} - -# Временное хранилище для создания тикетов (user_id -> True) -ticket_creation = {} - -# Временное хранилище для дополнения тикетов (user_id -> ticket_id) -ticket_append_mode = {} - - -def is_admin(user_id: int) -> bool: - """Проверка, является ли пользователь администратором""" - return user_id in config.admin_ids - - -async def get_user_locale(user_id: int) -> str: - """Получение языка пользователя из БД""" - return await db.get_user_language(user_id) - - -@main_router.message(Command("start")) -async def handle_start(message: types.Message): - """Обработчик команды /start""" - user_id = message.from_user.id - username = message.from_user.username or message.from_user.first_name - locale = await get_user_locale(user_id) - - # Создаём пользователя в БД если нет - await db.create_user(user_id, username) - - # Проверяем статус подписки - is_active = await db.get_user_subscription(user_id) - - subscription_status = ( - localization.get("subscription_active", locale) if is_active - else localization.get("subscription_inactive", locale) - ) - - # Формируем сообщение - text = ( - f"{localization.get('welcome', locale, username=username)}\n\n" - f"{localization.get('subscription_status', locale, status=subscription_status)}\n\n" - f"{localization.get('select_action', locale)}" - ) - - # Проверяем, является ли пользователь администратором - if is_admin(user_id): - # Добавляем кнопку админ-панели - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="💳 Купить подписку", - callback_data="buy_subscription" - ) - ], - [ - InlineKeyboardButton( - text="📞 Техподдержка", - callback_data="support" - ), - InlineKeyboardButton( - text="📜 Правила сервиса", - callback_data="rules" - ) - ], - [ - InlineKeyboardButton( - text="🌐 Язык", - callback_data="language" - ), - InlineKeyboardButton( - text="⚙️ Админ", - callback_data="admin_panel" - ) - ] - ] - ) - else: - keyboard = Keyboards.get_main_keyboard(locale) - - await message.answer( - text, - reply_markup=keyboard - ) - - -@main_router.callback_query(lambda c: c.data == "buy_subscription") -async def handle_callback_buy_subscription(callback: types.CallbackQuery): - """Обработчик кнопки 'Купить подписку'""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - - subscription_title = localization.get("subscription.standard_title", locale) - subscription_desc = localization.get("subscription.standard_description", locale) - - await callback.message.edit_text( - f"{subscription_title}\n\n{subscription_desc}", - reply_markup=Keyboards.get_buy_subscription_keyboard(locale) - ) - await callback.answer() - - -@main_router.callback_query(lambda c: c.data == "back_to_buy") -async def handle_callback_back_to_buy(callback: types.CallbackQuery): - """Возврат к выбору тарифа""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - - subscription_title = localization.get("subscription.standard_title", locale) - subscription_desc = localization.get("subscription.standard_description", locale) - - await callback.message.edit_text( - f"{subscription_title}\n\n{subscription_desc}", - reply_markup=Keyboards.get_buy_subscription_keyboard(locale) - ) - await callback.answer() - - -@main_router.callback_query(lambda c: c.data == "support") -async def handle_callback_support(callback: types.CallbackQuery): - """Обработчик кнопки 'Техподдержка'""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - - support_title = localization.get("messages.support_title", locale) - support_desc = localization.get("messages.support_description", locale) - - await callback.message.edit_text( - f"{support_title}\n\n{support_desc}", - reply_markup=Keyboards.get_support_keyboard(locale) - ) - await callback.answer() - - -@main_router.callback_query(lambda c: c.data == "support_create_ticket") -async def handle_support_create_ticket(callback: types.CallbackQuery): - """Обработчик кнопки 'Создать тикет'""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - - # Устанавливаем флаг, что пользователь создаёт тикет - ticket_creation[user_id] = True - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="❌ Отмена", - callback_data="cancel_ticket" - ), - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_support" - ) - ] - ] - ) - - await callback.message.edit_text( - "📝 Создание тикета\n\n" - "Опишите вашу проблему или вопрос одним сообщением.\n\n" - "Пример: 'Не работает VPN, ошибка подключения'\n\n" - "Отправьте сообщение ниже или нажмите 'Отмена':", - reply_markup=keyboard - ) - await callback.answer() - - -@main_router.callback_query(lambda c: c.data == "cancel_ticket") -async def handle_cancel_ticket(callback: types.CallbackQuery): - """Отмена создания тикета""" - user_id = callback.from_user.id - - # Удаляем флаг создания тикета - if user_id in ticket_creation: - del ticket_creation[user_id] - - locale = await get_user_locale(user_id) - - await callback.message.edit_text( - "❌ Создание тикета отменено.\n\n" - "Если у вас возникнут вопросы, вы всегда можете создать тикет позже.", - reply_markup=InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_support" - ) - ] - ] - ) - ) - await callback.answer() - - -@main_router.callback_query(lambda c: c.data == "support_my_tickets") -async def handle_support_my_tickets(callback: types.CallbackQuery): - """Обработчик кнопки 'Мои тикеты'""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - - # Получаем тикеты пользователя из БД - tickets = await db.get_user_tickets(user_id) - - if not tickets: - await callback.message.edit_text( - "📋 Мои тикеты\n\n" - "У вас пока нет активных тикетов.\n\n" - "Создайте новый тикет, если у вас есть вопрос или проблема.", - reply_markup=InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_support" - ) - ] - ] - ) - ) - else: - # Формируем список тикетов - tickets_text = "📋 Мои тикеты\n\n" - - for ticket in tickets[:5]: # Показываем последние 5 тикетов - status_emoji = "🟢" if ticket['status'] == 'open' else "🔵" if ticket['status'] == 'answered' else "⚫" - created_at = ticket['created_at'].strftime("%d.%m.%Y %H:%M") if ticket['created_at'] else 'Неизвестно' - - tickets_text += ( - f"{status_emoji} Тикет #{ticket['id']} - {ticket['status']}\n" - f"Дата: {created_at}\n" - f"Вопрос: {ticket['message'][:50]}...\n\n" - ) - - if len(tickets) > 5: - tickets_text += f"... и ещё {len(tickets) - 5} тикетов\n" - - # Клавиатура с кнопками тикетов - ticket_buttons = [] - for ticket in tickets[:5]: - status_emoji = "🟢" if ticket['status'] == 'open' else "🔵" if ticket['status'] == 'answered' else "⚫" - ticket_buttons.append([ - InlineKeyboardButton( - text=f"{status_emoji} #{ticket['id']} - {ticket['status']}", - callback_data=f"ticket_view_{ticket['id']}" - ) - ]) - ticket_buttons.append([ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_support" - ) - ]) - - await callback.message.edit_text( - tickets_text, - reply_markup=InlineKeyboardMarkup(inline_keyboard=ticket_buttons) - ) - - await callback.answer() - - -@main_router.callback_query(lambda c: c.data.startswith("ticket_view_")) -async def handle_ticket_view(callback: types.CallbackQuery): - """Просмотр тикета""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - - ticket_id = int(callback.data.replace("ticket_view_", "")) - await show_ticket_view(callback, user_id, locale, ticket_id) - - -async def show_ticket_view(callback, user_id, locale, ticket_id): - """Отображение просмотра тикета""" - ticket = await db.get_ticket(ticket_id) - - if not ticket or ticket['user_id'] != user_id: - await callback.answer("Тикет не найден", show_alert=True) - return - - # Статус тикета - status_text = { - 'open': '🟢 Открыт', - 'answered': '🔵 Дан ответ', - 'closed': '⚫ Закрыт' - }.get(ticket['status'], ticket['status']) - - # Формируем сообщение - ticket_text = f"📋 Тикет #{ticket['id']}\n\n" - ticket_text += f"Статус: {status_text}\n" - ticket_text += f"Дата создания: {ticket['created_at'].strftime('%d.%m.%Y %H:%M') if ticket['created_at'] else 'Неизвестно'}\n\n" - ticket_text += f"❓ Ваш вопрос:\n{ticket['message']}\n\n" - - if ticket['admin_response']: - ticket_text += f"💬 Ответ поддержки:\n{ticket['admin_response']}\n\n" - - # Информация о закрытии - if ticket['status'] == 'closed': - closed_by = ticket.get('closed_by', 'Неизвестно') - closed_at = ticket.get('closed_at') - closed_info = f"🔒 Закрыл: {closed_by}" - if closed_at: - closed_info += f"\n🕒 Дата: {closed_at.strftime('%d.%m.%Y %H:%M') if hasattr(closed_at, 'strftime') else closed_at}" - ticket_text += f"{closed_info}\n\n" - - if not ticket['admin_response'] and ticket['status'] != 'closed': - ticket_text += "⏳ Ожидайте ответа поддержки...\n\n" - - # Кнопки - keyboard_buttons = [ - [ - InlineKeyboardButton( - text="✏️ Дополнить тикет", - callback_data=f"ticket_append_{ticket['id']}" - ) - ], - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="support_my_tickets" - ) - ] - ] - - # Если тикет открыт, можно закрыть - if ticket['status'] == 'open': - keyboard_buttons.insert(0, [ - InlineKeyboardButton( - text="✅ Закрыть тикет", - callback_data=f"ticket_close_{ticket['id']}" - ) - ]) - - await callback.message.edit_text( - ticket_text, - reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) - ) - await callback.answer() - - -@main_router.callback_query(lambda c: c.data.startswith("ticket_close_")) -async def handle_ticket_close(callback: types.CallbackQuery): - """Закрытие тикета""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - username = callback.from_user.username or callback.from_user.first_name - - ticket_id = int(callback.data.replace("ticket_close_", "")) - ticket = await db.get_ticket(ticket_id) - - if not ticket or ticket['user_id'] != user_id: - await callback.answer("Тикет не найден", show_alert=True) - return - - # Закрываем тикет с указанием кто закрыл (передаём позиционно, не именованно) - await db.close_ticket(ticket_id, f"@{username}" if callback.from_user.username else f"{username}") - - await callback.answer("Тикет закрыт") - - # Обновляем сообщение с информацией кто закрыл (передаём ticket_id) - try: - await handle_ticket_view_by_id(callback, ticket_id) - except Exception as e: - if "message is not modified" not in str(e): - print(f"Ошибка обновления сообщения: {e}") - - -async def handle_ticket_view_by_id(callback, ticket_id): - """Просмотр тикета по ID (для вызова из других функций)""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - await show_ticket_view(callback, user_id, locale, ticket_id) - - -@main_router.callback_query(lambda c: c.data.startswith("ticket_append_")) -async def handle_ticket_append(callback: types.CallbackQuery): - """Режим дополнения тикета""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - - ticket_id = int(callback.data.replace("ticket_append_", "")) - ticket = await db.get_ticket(ticket_id) - - if not ticket or ticket['user_id'] != user_id: - await callback.answer("Тикет не найден", show_alert=True) - return - - # Устанавливаем режим дополнения - ticket_append_mode[user_id] = ticket_id - - await callback.message.edit_text( - f"✏️ Дополнение тикета #{ticket_id}\n\n" - f"Напишите дополнение к вашему тикету.\n\n" - f"Текущий статус: {ticket['status']}\n\n" - f"Отправьте сообщение ниже или нажмите 'Отмена':", - reply_markup=InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"ticket_append_cancel_{ticket_id}" - ) - ] - ] - ) - ) - await callback.answer() - - -@main_router.callback_query(lambda c: c.data.startswith("ticket_append_cancel_")) -async def handle_ticket_append_cancel(callback: types.CallbackQuery): - """Отмена дополнения тикета""" - user_id = callback.from_user.id - - # Удаляем из режима дополнения - if user_id in ticket_append_mode: - del ticket_append_mode[user_id] - - # Возвращаемся к просмотру тикета - ticket_id = int(callback.data.replace("ticket_append_cancel_", "")) - - # Обновляем сообщение - await handle_ticket_view(callback) - await callback.answer("Отменено") - - -@main_router.callback_query(lambda c: c.data == "back_to_support") -async def handle_callback_back_to_support(callback: types.CallbackQuery): - """Возврат в меню поддержки""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - - support_title = localization.get("messages.support_title", locale) - support_desc = localization.get("messages.support_description", locale) - - await callback.message.edit_text( - f"{support_title}\n\n{support_desc}", - reply_markup=Keyboards.get_support_keyboard(locale) - ) - await callback.answer() - - -@main_router.callback_query(lambda c: c.data == "rules") -async def handle_callback_rules(callback: types.CallbackQuery): - """Обработчик кнопки 'Правила сервиса'""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - - rules_title = localization.get("messages.rules_title", locale) - rules_text = localization.get("messages.rules_text", locale) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_main" - ) - ] - ] - ) - - await callback.message.edit_text( - f"{rules_title}\n\n{rules_text}", - reply_markup=keyboard - ) - await callback.answer() - - -@main_router.callback_query(lambda c: c.data == "language") -async def handle_callback_language(callback: types.CallbackQuery): - """Обработчик кнопки 'Язык'""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - - await callback.message.edit_text( - localization.get("messages.select_language", locale), - reply_markup=Keyboards.get_language_keyboard(locale) - ) - await callback.answer() - - -@main_router.callback_query(lambda c: c.data == "back_to_main") -async def handle_callback_back_to_main(callback: types.CallbackQuery): - """Возврат в главное меню""" - user_id = callback.from_user.id - username = callback.from_user.username or callback.from_user.first_name - locale = await get_user_locale(user_id) - - is_active = await db.get_user_subscription(user_id) - - subscription_status = ( - localization.get("subscription_active", locale) if is_active - else localization.get("subscription_inactive", locale) - ) - - text = ( - f"{localization.get('welcome', locale, username=username)}\n\n" - f"{localization.get('subscription_status', locale, status=subscription_status)}\n\n" - f"{localization.get('select_action', locale)}" - ) - - # Проверяем, является ли пользователь администратором - if is_admin(user_id): - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="💳 Купить подписку", - callback_data="buy_subscription" - ) - ], - [ - InlineKeyboardButton( - text="📞 Техподдержка", - callback_data="support" - ), - InlineKeyboardButton( - text="📜 Правила сервиса", - callback_data="rules" - ) - ], - [ - InlineKeyboardButton( - text="🌐 Язык", - callback_data="language" - ), - InlineKeyboardButton( - text="⚙️ Админ", - callback_data="admin_panel" - ) - ] - ] - ) - else: - keyboard = Keyboards.get_main_keyboard(locale) - - await callback.message.edit_text( - text=text, - reply_markup=keyboard - ) - await callback.answer() - - -@main_router.callback_query(lambda c: c.data.startswith("lang_")) -async def handle_callback_language_change(callback: types.CallbackQuery): - """Обработчик смены языка""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - - lang = callback.data.replace("lang_", "") - - # Сохраняем язык в БД - await db.set_user_language(user_id, lang) - - await callback.answer( - localization.get("messages.language_changed", locale, language=localization.get_locale_name(lang)) - ) - - # Возвращаемся в главное меню - await handle_callback_back_to_main(callback) - - -@main_router.callback_query(lambda c: c.data in ["buy_14_days", "buy_30_days", "buy_60_days", "buy_90_days", "buy_180_days", "buy_360_days"]) -async def handle_callback_buy_tariff(callback: types.CallbackQuery): - """Обработчик выбора тарифа""" - user_id = callback.from_user.id - locale = await get_user_locale(user_id) - - # Карта тарифов: callback_data -> (days, price) - tariffs_map = { - "buy_14_days": (14, "150₽"), - "buy_30_days": (30, "300₽"), - "buy_60_days": (60, "600₽"), - "buy_90_days": (90, "900₽"), - "buy_180_days": (180, "1800₽"), - "buy_360_days": (360, "3600₽") - } - - tariff_info = tariffs_map.get(callback.data) - if tariff_info: - days, price = tariff_info - # Сохраняем тариф во временное хранилище - payment_temp[user_id] = {"days": days, "price": price, "tariff": callback.data} - - payment_title = localization.get("messages.payment_instruction_title", locale) - payment_text = localization.get("messages.payment_instruction_text", locale, phone=config.payment_phone) - - # Клавиатура с кнопкой "Назад" - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_buy" - ) - ] - ] - ) - - await callback.message.edit_text( - f"{payment_title}\n\n{payment_text}", - reply_markup=keyboard - ) - await callback.answer() - - -@main_router.message(F.photo) -async def handle_payment_photo(message: types.Message): - """Обработчик фото чека""" - user_id = message.from_user.id - username = message.from_user.username or message.from_user.first_name - - # Получаем информацию о тарифе из временного хранилища - tariff_info = payment_temp.get(user_id, {}) - days = tariff_info.get("days", 0) - tariff = tariff_info.get("tariff", "unknown") - - # Отправляем фото в канал (в тему 5910) с кнопками аппрува - if config.payment_channel_id: - try: - await message.bot.copy_message( - chat_id=config.payment_channel_id, - from_chat_id=message.chat.id, - message_id=message.message_id, - caption=f"💳 Оплата от @{username} ({user_id})\nДней: {days}\nТариф: {tariff}\n{message.caption or ''}", - message_thread_id=5910, - reply_markup=PaymentKeyboards.get_payment_approval_keyboard(user_id, tariff, days) - ) - print(f"✅ Фото отправлено в канал {config.payment_channel_id}, тема 5910") - except Exception as e: - print(f"❌ Ошибка отправки фото в канал: {e}") - - # Отвечаем пользователю с кнопкой "В главное меню" - locale = await get_user_locale(user_id) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_main" - ) - ] - ] - ) - - await message.answer( - localization.get("messages.payment_receipt_sent", locale), - reply_markup=keyboard - ) - - -@main_router.message(F.text) -async def handle_ticket_message(message: types.Message): - """Обработчик текстовых сообщений для создания/дополнения тикета""" - user_id = message.from_user.id - username = message.from_user.username or message.from_user.first_name - text = message.text - - # Проверяем, отвечает ли админ на тикет - admin_reply_mode = ticket_append_mode.get(user_id) - if admin_reply_mode and isinstance(admin_reply_mode, str) and admin_reply_mode.startswith("admin_reply_"): - ticket_id = int(admin_reply_mode.replace("admin_reply_", "")) - - # Сохраняем ответ в БД - await db.update_ticket_response(ticket_id, text) - - # Отправляем ответ в канал - ticket_channel_id = config.payment_ticket_channel_id or config.payment_channel_id - ticket_thread_id = config.payment_ticket_thread_id - - if ticket_channel_id: - try: - if ticket_thread_id: - await message.bot.send_message( - chat_id=ticket_channel_id, - text=f"💬 Ответ администратора в тикет #{ticket_id}:\n\n{text}", - message_thread_id=int(ticket_thread_id) - ) - else: - await message.bot.send_message( - chat_id=ticket_channel_id, - text=f"💬 Ответ администратора в тикет #{ticket_id}:\n\n{text}" - ) - print(f"✅ Ответ админа отправлен в тикет #{ticket_id}") - except Exception as e: - print(f"Ошибка отправки ответа админа: {e}") - - # Отвечаем пользователю - try: - ticket = await db.get_ticket(ticket_id) - if ticket: - await message.bot.send_message( - chat_id=ticket['user_id'], - text=f"📋 Ответ поддержки в вашем тикете #{ticket_id}:\n\n{text}" - ) - except Exception as e: - print(f"Не удалось отправить ответ пользователю: {e}") - - # Удаляем режим ответа - del ticket_append_mode[user_id] - - await message.answer( - f"✅ Ответ отправлен в тикет #{ticket_id}!\n\nПользователь получит уведомление.", - reply_markup=InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="🔙 Назад в админку", - callback_data="admin_back" - ) - ] - ] - ) - ) - return - - # Проверяем, создаёт ли пользователь тикет - if ticket_creation.get(user_id): - # Сохраняем тикет в БД - ticket_id = await db.create_ticket(user_id, username, text) - - # Отправляем тикет в канал (с thread_id) - ticket_channel_id = config.payment_ticket_channel_id or config.payment_channel_id - ticket_thread_id = config.payment_ticket_thread_id - - if ticket_channel_id: - try: - if ticket_thread_id: - # Отправляем в тему (форум) - await message.bot.send_message( - chat_id=ticket_channel_id, - text=f"📝 Новый тикет #{ticket_id} от @{username} ({user_id})\n\n❓ Вопрос:\n{text}", - message_thread_id=int(ticket_thread_id) - ) - else: - # Отправляем просто в канал - await message.bot.send_message( - chat_id=ticket_channel_id, - text=f"📝 Новый тикет #{ticket_id} от @{username} ({user_id})\n\n❓ Вопрос:\n{text}" - ) - print(f"✅ Тикет #{ticket_id} отправлен в канал {ticket_channel_id}, тема {ticket_thread_id}") - except Exception as e: - print(f"Ошибка отправки тикета в канал: {e}") - - # Отвечаем пользователю - locale = await get_user_locale(user_id) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_main" - ) - ] - ] - ) - - # Отправляем сообщение и сразу удаляем флаг - del ticket_creation[user_id] # Удаляем флаг ПЕРЕД отправкой - - await message.answer( - f"✅ Тикет #{ticket_id} создан!\n\nНаша поддержка ответит вам в ближайшее время.\n\nОжидайте ответа в этом чате.", - reply_markup=keyboard - ) - - # Проверяем, дополняет ли пользователь тикет - elif ticket_append_mode.get(user_id): - ticket_id = ticket_append_mode[user_id] - - # Добавляем сообщение к тикету - await db.append_ticket_message(ticket_id, text) - - # Отправляем уведомление в канал - ticket_channel_id = config.payment_ticket_channel_id or config.payment_channel_id - ticket_thread_id = config.payment_ticket_thread_id - - if ticket_channel_id: - try: - if ticket_thread_id: - await message.bot.send_message( - chat_id=ticket_channel_id, - text=f"➕ Тикет #{ticket_id} дополнен от @{username} ({user_id})\n\n📝 Дополнение:\n{text}", - message_thread_id=int(ticket_thread_id) - ) - else: - await message.bot.send_message( - chat_id=ticket_channel_id, - text=f"➕ Тикет #{ticket_id} дополнен от @{username} ({user_id})\n\n📝 Дополнение:\n{text}" - ) - print(f"✅ Дополнение к тикуту #{ticket_id} отправлено в канал") - except Exception as e: - print(f"Ошибка отправки дополнения в канал: {e}") - - # Отвечаем пользователю - locale = await get_user_locale(user_id) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_main" - ) - ] - ] - ) - - # Удаляем режим дополнения - del ticket_append_mode[user_id] - - await message.answer( - f"✅ Тикет #{ticket_id} дополнен!\n\nПоддержка увидит ваше сообщение.", - reply_markup=keyboard - ) - - -# ==================== АДМИН ПАНЕЛЬ ==================== - -@admin_router.callback_query(lambda c: c.data == "admin_panel") -async def handle_admin_panel_button(callback: types.CallbackQuery): - """Админ-панель из кнопки""" - if not is_admin(callback.from_user.id): - await callback.answer("Доступ запрещён", show_alert=True) - return - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="📋 Все тикеты", - callback_data="admin_all_tickets" - ) - ], - [ - InlineKeyboardButton( - text="🔍 Тикет по ID", - callback_data="admin_ticket_by_id" - ) - ], - [ - InlineKeyboardButton( - text="👥 Пользователи", - callback_data="admin_users" - ) - ], - [ - InlineKeyboardButton( - text="⚙️ Активировать подписку", - callback_data="admin_activate_sub" - ) - ], - [ - InlineKeyboardButton( - text="🔙 Назад", - callback_data="back_to_main" - ) - ] - ] - ) - - await callback.message.edit_text( - "⚙️ Админ-панель\n\nВыберите действие:", - reply_markup=keyboard - ) - await callback.answer() - - -@admin_router.callback_query(lambda c: c.data == "admin_users") -async def handle_admin_users(callback: types.CallbackQuery): - """Управление пользователями - статистика""" - if not is_admin(callback.from_user.id): - await callback.answer("Доступ запрещён", show_alert=True) - return - - # Получаем статистику из БД - connection = db.get_connection() - cursor = connection.cursor(dictionary=True) - - # Всего пользователей - cursor.execute("SELECT COUNT(*) as count FROM subscriptions") - total_users = cursor.fetchone()['count'] - - # Активных пользователей - cursor.execute("SELECT COUNT(*) as count FROM subscriptions WHERE is_active = TRUE") - active_users = cursor.fetchone()['count'] - - # Заблокированных (пока нет такой колонки, считаем неактивных) - blocked_users = total_users - active_users - - # Новые пользователи сегодня - cursor.execute(""" - SELECT COUNT(*) as count FROM subscriptions - WHERE DATE(created_at) = CURDATE() - """) - today_users = cursor.fetchone()['count'] - - # Новые пользователи за неделю - cursor.execute(""" - SELECT COUNT(*) as count FROM subscriptions - WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) - """) - week_users = cursor.fetchone()['count'] - - # Новые пользователи за месяц - cursor.execute(""" - SELECT COUNT(*) as count FROM subscriptions - WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) - """) - month_users = cursor.fetchone()['count'] - - cursor.close() - connection.close() - - stats_text = ( - "👥 Управление пользователями\n\n" - "📊 Статистика:\n" - f"• Всего: {total_users}\n" - f"• Активных: {active_users}\n" - f"• Заблокированных: {blocked_users}\n\n" - "📈 Новые пользователи:\n" - f"• Сегодня: {today_users}\n" - f"• За неделю: {week_users}\n" - f"• За месяц: {month_users}" - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="� Все пользователи", - callback_data="admin_users_list_1" - ) - ], - [ - InlineKeyboardButton( - text="�📥 Импорт из Remnawave", - callback_data="admin_sync_remnawave" - ) - ], - [ - InlineKeyboardButton( - text="🔙 Назад в админку", - callback_data="admin_back" - ) - ] - ] - ) - - await callback.message.edit_text( - stats_text, - reply_markup=keyboard - ) - await callback.answer() - - -@admin_router.callback_query(lambda c: c.data.startswith("admin_users_list_")) -async def handle_admin_users_list(callback: types.CallbackQuery): - """Отображение списка всех пользователей с пагинацией""" - if not is_admin(callback.from_user.id): - await callback.answer("Доступ запрещён", show_alert=True) - return - - # Извлекаем номер страницы - page = int(callback.data.replace("admin_users_list_", "")) - per_page = 10 # Пользователей на странице - - # Получаем пользователей из БД - result = await db.get_all_users_paginated(page, per_page) - - users = result.get('users', []) - total = result.get('total', 0) - total_pages = result.get('total_pages', 0) - current_page = result.get('current_page', 1) - - if not users: - await callback.message.edit_text( - "Нажмите на пользователя для управления:", - reply_markup=InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="🔙 Назад к пользователям", - callback_data="admin_users" - ) - ] - ] - ) - ) - await callback.answer() - return - - # Формируем список пользователей - keyboard_buttons = [] - - from datetime import datetime, timezone - - for user in users: - user_id = user['user_id'] - username = user.get('username') or f"User {user_id}" - is_active = user.get('is_active', False) - created_at = user.get('created_at') - - # Формируем статус активности - status_emoji = "✅" if is_active else "❌" - - # Формируем иконку подписки (если активна) - sub_emoji = "💎" if is_active else "" - - # Подарок для новых пользователей (созданы сегодня) - gift_emoji = "" - if created_at: - if hasattr(created_at, 'date'): - if created_at.date() == datetime.now().date(): - gift_emoji = "🎁" - elif isinstance(created_at, str): - try: - created_date = datetime.fromisoformat(created_at) - if created_date.date() == datetime.now().date(): - gift_emoji = "🎁" - except: - pass - - # Вычисляем время с момента регистрации - time_ago = "" - if created_at: - if hasattr(created_at, 'replace'): - try: - now = datetime.now() - if hasattr(created_at, 'tzinfo') and created_at.tzinfo is not None: - now = now.replace(tzinfo=timezone.utc) - diff = now - created_at - seconds = int(diff.total_seconds()) - - if seconds < 60: - time_ago = f"{seconds} сек. назад" - elif seconds < 3600: - minutes = seconds // 60 - time_ago = f"{minutes} мин. назад" - elif seconds < 86400: - hours = seconds // 3600 - time_ago = f"{hours} ч. назад" - else: - days = seconds // 86400 - time_ago = f"{days} дн. назад" - except: - time_ago = str(created_at)[:10] - else: - time_ago = str(created_at)[:10] - - # Формируем текст для кнопки пользователя - user_button_text = f"{status_emoji} {gift_emoji}{sub_emoji} {username} | {time_ago}" - - # Ограничиваем длину текста кнопки (максимум 64 символа) - if len(user_button_text) > 64: - user_button_text = user_button_text[:61] + "..." - - keyboard_buttons.append([ - InlineKeyboardButton( - text=user_button_text, - callback_data=f"admin_user_view_{user_id}" - ) - ]) - - # Добавляем пагинацию - pagination_buttons = [] - - # Кнопка предыдущей страницы - if current_page > 1: - pagination_buttons.append( - InlineKeyboardButton( - text="⬅️", - callback_data=f"admin_users_list_{current_page - 1}" - ) - ) - - # Номер текущей страницы - pagination_buttons.append( - InlineKeyboardButton( - text=f"{current_page}/{total_pages}", - callback_data="noop" - ) - ) - - # Кнопка следующей страницы - if current_page < total_pages: - pagination_buttons.append( - InlineKeyboardButton( - text="➡️", - callback_data=f"admin_users_list_{current_page + 1}" - ) - ) - - keyboard_buttons.append(pagination_buttons) - - # Кнопка поиска - keyboard_buttons.append([ - InlineKeyboardButton( - text="🔍 Поиск", - callback_data="admin_user_search" - ) - ]) - - # Кнопка назад - keyboard_buttons.append([ - InlineKeyboardButton( - text="🔙 Назад", - callback_data="admin_users" - ) - ]) - - await callback.message.edit_text( - "Нажмите на пользователя для управления:", - reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) - ) - await callback.answer() - - -@admin_router.callback_query(lambda c: c.data == "admin_sync_remnawave") -async def handle_admin_sync_remnawave(callback: types.CallbackQuery): - """Синхронизация всех пользователей из Remnawave в БД""" - if not is_admin(callback.from_user.id): - await callback.answer("Доступ запрещён", show_alert=True) - return - - # Отправляем сообщение о начале синхронизации - progress_message = await callback.message.answer("🔄 Начинаю синхронизацию из Remnawave...\n\nЭто может занять несколько минут.") - - # Запускаем синхронизацию (импорт из Remnawave в БД) - result = await remnawave.sync_all_users() - - if result.get("success"): - result_text = ( - "✅ Синхронизация завершена!\n\n" - f"📊 Результаты:\n" - f"• Всего пользователей в Remnawave: {result['total']}\n" - f"• Импортировано новых: {result['imported']}\n" - f"• Обновлено существующих: {result['updated']}\n" - f"• Ошибок: {result['errors']}" - ) - - if result.get("error_details"): - result_text += f"\n\n⚠️ Ошибки (первые 10):\n" - for error in result["error_details"]: - result_text += f"• {error}\n" - else: - result_text = f"❌ Ошибка синхронизации: {result.get('error', 'Неизвестная ошибка')}" - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="🔙 Назад к пользователям", - callback_data="admin_users" - ) - ] - ] - ) - - try: - await progress_message.edit_text( - result_text, - reply_markup=keyboard - ) - except: - await callback.message.answer( - result_text, - reply_markup=keyboard - ) - - await callback.answer() - - -@admin_router.callback_query(lambda c: c.data.startswith("admin_user_view_")) -async def handle_admin_user_view(callback: types.CallbackQuery): - """Просмотр информации о пользователе""" - if not is_admin(callback.from_user.id): - await callback.answer("Доступ запрещён", show_alert=True) - return - - user_id = int(callback.data.replace("admin_user_view_", "")) - - # Получаем информацию о пользователе из БД - connection = db.get_connection() - cursor = connection.cursor(dictionary=True) - cursor.execute( - "SELECT * FROM subscriptions WHERE user_id = %s", - (user_id,) - ) - user = cursor.fetchone() - cursor.close() - connection.close() - - if not user: - await callback.answer("Пользователь не найден", show_alert=True) - return - - # Формируем текст с информацией о пользователе - user_text = f"👤 Информация о пользователе\n\n" - user_text += f"ID: {user['user_id']}\n" - user_text += f"Username: @{user['username'] or 'Не указан'}\n" - user_text += f"Статус: {'✅ Активен' if user['is_active'] else '❌ Неактивен'}\n\n" - - if user.get('subscription_start'): - user_text += f"Начало подписки: {user['subscription_start'].strftime('%d.%m.%Y %H:%M') if hasattr(user['subscription_start'], 'strftime') else user['subscription_start']}\n" - - if user.get('subscription_end'): - user_text += f"Окончание подписки: {user['subscription_end'].strftime('%d.%m.%Y %H:%M') if hasattr(user['subscription_end'], 'strftime') else user['subscription_end']}\n" - - user_text += f"\nДата регистрации: {user['created_at'].strftime('%d.%m.%Y %H:%M') if hasattr(user['created_at'], 'strftime') else user['created_at']}\n" - - # Кнопки управления пользователем - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="✅ Активировать подписку" if not user['is_active'] else "❌ Деактивировать", - callback_data=f"admin_user_toggle_sub_{user_id}" - ) - ], - [ - InlineKeyboardButton( - text="📋 Тикеты пользователя", - callback_data=f"admin_user_tickets_{user_id}" - ) - ], - [ - InlineKeyboardButton( - text="🔙 Назад к списку", - callback_data="admin_users_list_1" - ) - ] - ] - ) - - await callback.message.edit_text( - user_text, - reply_markup=keyboard - ) - await callback.answer() - - -@admin_router.callback_query(lambda c: c.data == "admin_user_search") -async def handle_admin_user_search(callback: types.CallbackQuery): - """Заглушка для поиска пользователей""" - await callback.answer("Функция поиска скоро будет доступна", show_alert=True) - - -@admin_router.callback_query(lambda c: c.data == "noop") -async def handle_noop(callback: types.CallbackQuery): - """Заглушка для неактивных кнопок""" - await callback.answer() - - -@admin_router.callback_query(lambda c: c.data.startswith("admin_user_toggle_sub_")) -async def handle_admin_user_toggle_sub(callback: types.CallbackQuery): - """Переключение статуса подписки пользователя""" - user_id = int(callback.data.replace("admin_user_toggle_sub_", "")) - - if not is_admin(callback.from_user.id): - await callback.answer("Доступ запрещён", show_alert=True) - return - - # Получаем текущего пользователя - connection = db.get_connection() - cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT is_active FROM subscriptions WHERE user_id = %s", (user_id,)) - user = cursor.fetchone() - - if not user: - await callback.answer("Пользователь не найден", show_alert=True) - cursor.close() - connection.close() - return - - # Переключаем статус - new_status = not user['is_active'] - if new_status: - # Активируем подписку на 30 дней - cursor.execute( - """UPDATE subscriptions - SET is_active = TRUE, - subscription_start = NOW(), - subscription_end = DATE_ADD(NOW(), INTERVAL 30 DAY) - WHERE user_id = %s""", - (user_id,) - ) - else: - # Деактивируем подписку - cursor.execute( - "UPDATE subscriptions SET is_active = FALSE WHERE user_id = %s", - (user_id,) - ) - - connection.commit() - cursor.close() - connection.close() - - await callback.answer(f"Подписка {'активирована' if new_status else 'деактивирована'}") - - # Обновляем экран - await handle_admin_user_view(callback) - - -@admin_router.callback_query(lambda c: c.data.startswith("admin_user_tickets_")) -async def handle_admin_user_tickets(callback: types.CallbackQuery): - """Просмотр тикетов пользователя""" - user_id = int(callback.data.replace("admin_user_tickets_", "")) - - if not is_admin(callback.from_user.id): - await callback.answer("Доступ запрещён", show_alert=True) - return - - # Получаем тикеты пользователя - tickets = await db.get_user_tickets(user_id) - - if not tickets: - await callback.message.answer( - "📋 Тикеты пользователя\n\n" - "У пользователя нет тикетов.", - reply_markup=InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="🔙 Назад к пользователю", - callback_data=f"admin_user_view_{user_id}" - ) - ] - ] - ) - ) - await callback.answer() - return - - # Формируем список тикетов - tickets_text = f"📋 Тикеты пользователя\n\n" - keyboard_buttons = [] - - for ticket in tickets[:10]: - status_emoji = "🟢" if ticket['status'] == 'open' else "🔵" if ticket['status'] == 'answered' else "⚫" - keyboard_buttons.append([ - InlineKeyboardButton( - text=f"{status_emoji} #{ticket['id']} - {ticket['status']}", - callback_data=f"admin_ticket_view_{ticket['id']}" - ) - ]) - - keyboard_buttons.append([ - InlineKeyboardButton( - text="🔙 Назад к пользователю", - callback_data=f"admin_user_view_{user_id}" - ) - ]) - - await callback.message.answer( - tickets_text, - reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) - ) - await callback.answer() - - -@admin_router.callback_query(lambda c: c.data == "admin_all_tickets") -async def handle_admin_all_tickets(callback: types.CallbackQuery): - """Все тикеты (последние 10)""" - if not is_admin(callback.from_user.id): - await callback.answer("Доступ запрещён", show_alert=True) - return - - # Получаем все тикеты (последние 10) - connection = db.get_connection() - cursor = connection.cursor(dictionary=True) - cursor.execute( - """SELECT id, user_id, username, message, status, created_at - FROM tickets ORDER BY created_at DESC LIMIT 10""" - ) - tickets = cursor.fetchall() - cursor.close() - connection.close() - - if not tickets: - await callback.answer("Тикетов нет", show_alert=True) - return - - tickets_text = "📋 Последние тикеты (10):\n\n" - keyboard_buttons = [] - - for ticket in tickets: - status_emoji = "🟢" if ticket['status'] == 'open' else "🔵" if ticket['status'] == 'answered' else "⚫" - created_at = ticket['created_at'].strftime("%d.%m.%Y %H:%M") if ticket['created_at'] else 'Неизвестно' - - tickets_text += ( - f"{status_emoji} #{ticket['id']} | @{ticket['username']} | {ticket['status']}\n" - ) - - keyboard_buttons.append([ - InlineKeyboardButton( - text=f"#{ticket['id']} - @{ticket['username']}", - callback_data=f"admin_ticket_view_{ticket['id']}" - ) - ]) - - keyboard_buttons.append([ - InlineKeyboardButton( - text="🔙 Назад", - callback_data="admin_back" - ) - ]) - - await callback.message.answer( - tickets_text, - reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) - ) - await callback.answer() - - -@admin_router.callback_query(lambda c: c.data.startswith("admin_ticket_view_")) -async def handle_admin_ticket_view(callback: types.CallbackQuery): - """Просмотр тикета админом""" - if not is_admin(callback.from_user.id): - await callback.answer("Доступ запрещён", show_alert=True) - return - - ticket_id = int(callback.data.replace("admin_ticket_view_", "")) - ticket = await db.get_ticket(ticket_id) - - if not ticket: - await callback.answer("Тикет не найден", show_alert=True) - return - - status_text = { - 'open': '🟢 Открыт', - 'answered': '🔵 Дан ответ', - 'closed': '⚫ Закрыт' - }.get(ticket['status'], ticket['status']) - - ticket_text = f"📋 Тикет #{ticket['id']}\n\n" - ticket_text += f"Пользователь: @{ticket['username']} ({ticket['user_id']})\n" - ticket_text += f"Статус: {status_text}\n" - ticket_text += f"Дата создания: {ticket['created_at'].strftime('%d.%m.%Y %H:%M') if ticket['created_at'] else 'Неизвестно'}\n\n" - ticket_text += f"❓ Вопрос:\n{ticket['message']}\n\n" - - if ticket['admin_response']: - ticket_text += f"💬 Ответ поддержки:\n{ticket['admin_response']}\n\n" - - if ticket['status'] == 'closed': - closed_by = ticket.get('closed_by', 'Неизвестно') - closed_at = ticket.get('closed_at') - ticket_text += f"🔒 Закрыл: {closed_by}\n" - if closed_at: - ticket_text += f"🕒 Дата: {closed_at.strftime('%d.%m.%Y %H:%M') if hasattr(closed_at, 'strftime') else closed_at}\n" - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="✏️ Ответить", - callback_data=f"admin_reply_{ticket['id']}" - ) - ], - [ - InlineKeyboardButton( - text="✅ Закрыть", - callback_data=f"admin_close_{ticket['id']}" - ) - ], - [ - InlineKeyboardButton( - text="🔙 Назад", - callback_data="admin_all_tickets" - ) - ] - ] - ) - - await callback.message.answer(ticket_text, reply_markup=keyboard) - await callback.answer() - - -@admin_router.callback_query(lambda c: c.data.startswith("admin_reply_")) -async def handle_admin_reply(callback: types.CallbackQuery): - """Ответ на тикет""" - if not is_admin(callback.from_user.id): - await callback.answer("Доступ запрещён", show_alert=True) - return - - ticket_id = int(callback.data.replace("admin_reply_", "")) - - # Устанавливаем режим ответа - ticket_append_mode[callback.from_user.id] = f"admin_reply_{ticket_id}" - - await callback.message.answer( - f"✏️ Ответ на тикет #{ticket_id}\n\n" - f"Напишите ваш ответ одним сообщением:", - reply_markup=InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="❌ Отмена", - callback_data="admin_cancel_reply" - ) - ] - ] - ) - ) - await callback.answer() - - -@admin_router.callback_query(lambda c: c.data == "admin_cancel_reply") -async def handle_admin_cancel_reply(callback: types.CallbackQuery): - """Отмена ответа админа""" - if callback.from_user.id in ticket_append_mode: - del ticket_append_mode[callback.from_user.id] - - await callback.message.edit_text( - "❌ Отменено\n\nВыберите действие:", - reply_markup=InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="🔙 Назад в админку", - callback_data="admin_back" - ) - ] - ] - ) - ) - await callback.answer() - - -@admin_router.callback_query(lambda c: c.data.startswith("admin_close_")) -async def handle_admin_close(callback: types.CallbackQuery): - """Закрытие тикета админом""" - if not is_admin(callback.from_user.id): - await callback.answer("Доступ запрещён", show_alert=True) - return - - ticket_id = int(callback.data.replace("admin_close_", "")) - admin_username = callback.from_user.username or callback.from_user.first_name - - await db.close_ticket(ticket_id, f"admin:{admin_username}") - - # Уведомляем пользователя - ticket = await db.get_ticket(ticket_id) - if ticket: - try: - await callback.bot.send_message( - chat_id=ticket['user_id'], - text=f"📋 Ваш тикет #{ticket_id} был закрыт администратором.\n\n" - f"Если у вас остались вопросы, создайте новый тикет." - ) - except: - pass - - await callback.answer("Тикет закрыт") - - # Обновляем сообщение - try: - await callback.message.delete() - except: - pass - - -@admin_router.callback_query(lambda c: c.data == "admin_back") -async def handle_admin_back(callback: types.CallbackQuery): - """Назад в админку""" - if not is_admin(callback.from_user.id): - await callback.answer("Доступ запрещён", show_alert=True) - return - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="📋 Все тикеты", - callback_data="admin_all_tickets" - ) - ], - [ - InlineKeyboardButton( - text="🔍 Тикет по ID", - callback_data="admin_ticket_by_id" - ) - ], - [ - InlineKeyboardButton( - text="👥 Пользователи", - callback_data="admin_users" - ) - ], - [ - InlineKeyboardButton( - text="⚙️ Активировать подписку", - callback_data="admin_activate_sub" - ) - ], - [ - InlineKeyboardButton( - text="🔙 Назад", - callback_data="back_to_main" - ) - ] - ] - ) - - await callback.message.edit_text( - "⚙️ Админ-панель\n\nВыберите действие:", - reply_markup=keyboard - ) - await callback.answer() - - -@main_router.callback_query(lambda c: c.data.startswith("payment_approve_")) -async def handle_payment_approve(callback: types.CallbackQuery): - """Обработчик кнопки 'Аппрув'""" - # payment_approve_{user_id}_{tariff}_{days} - data = callback.data.replace("payment_approve_", "").split("_") - - # Получаем locale для клавиатур - locale = await get_user_locale(callback.from_user.id) - - if len(data) >= 3: - user_id = int(data[0]) - tariff = data[1] - days = int(data[2]) - - # Создаём подписку через Remnawave API - api_result = await remnawave.create_subscription(user_id, days, tariff) - - if api_result.get("success"): - # Активируем подписку в локальной БД - await db.activate_subscription(user_id, days) - - # Формируем сообщение для пользователя - subscription_url = api_result.get("subscription_url") - username = api_result.get("username", f"subs{user_id}") - - print(f"🔗 subscription_url: {subscription_url}") - print(f"👤 username: {username}") - - if subscription_url: - user_message = ( - f"✅ Оплата подтверждена!\n\n" - f"Ваша подписка активирована на {days} дней.\n\n" - f"Ссылка на подключение : {subscription_url}\n\n" - f"Нажмите на ссылку выше, чтобы добавить VPN в приложение.\n\n" - f"Спасибо за покупку! 🎉" - ) - else: - user_message = ( - f"✅ Оплата подтверждена!\n\n" - f"Ваша подписка активирована на {days} дней.\n" - f"Username: {username}\n\n" - f"Ссылка на подключение будет отправлена администратором.\n\n" - f"Спасибо за покупку! 🎉" - ) - - # Клавиатура с кнопкой "В главное меню" - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_main" - ) - ] - ] - ) - - # Отправляем уведомление пользователю - try: - await callback.bot.send_message( - chat_id=user_id, - text=user_message, - reply_markup=keyboard - ) - print(f"✅ Сообщение отправлено пользователю {user_id}") - except Exception as e: - print(f"❌ Не удалось отправить уведомление пользователю {user_id}: {e}") - else: - # Ошибка API - error_msg = f"⚠️ Подписка не создана\n\nОшибка: {api_result.get('error', 'Неизвестная ошибка')}\n\nОбратитесь в техподдержку." - - # Клавиатура с кнопкой "В главное меню" - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_main" - ) - ] - ] - ) - - try: - await callback.bot.send_message( - chat_id=user_id, - text=error_msg, - reply_markup=keyboard - ) - except: - pass - - # Удаляем кнопки из сообщения в канале - try: - await callback.message.edit_reply_markup(reply_markup=None) - except: - pass - - # Добавляем пометку в канал - try: - status_text = "✅ ОПЛАЧЕНО" if api_result.get("success") else "⚠️ ОШИБКА API" - await callback.message.edit_caption( - caption=f"{callback.message.caption}\n\n{status_text}", - reply_markup=None - ) - except: - pass - - await callback.answer("Готово!") - - -@main_router.callback_query(lambda c: c.data.startswith("payment_decline_")) -async def handle_payment_decline(callback: types.CallbackQuery): - """Обработчик кнопки 'Отклонить'""" - # payment_decline_{user_id}_{tariff} - data = callback.data.replace("payment_decline_", "").split("_") - - # Получаем locale для клавиатур - locale = await get_user_locale(callback.from_user.id) - - if len(data) >= 2: - user_id = int(data[0]) - - # Клавиатура с кнопкой "В главное меню" - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_main" - ) - ] - ] - ) - - # Отправляем уведомление пользователю - try: - await callback.bot.send_message( - chat_id=user_id, - text=f"❌ Оплата отклонена.\n\nПо вопросам обращайтесь в техподдержку.", - reply_markup=keyboard - ) - except Exception as e: - print(f"Не удалось отправить уведомление пользователю {user_id}: {e}") - - # Удаляем кнопки из сообщения в канале - try: - await callback.message.edit_reply_markup(reply_markup=None) - except: - pass - - # Добавляем пометку в канал - try: - await callback.message.edit_caption( - caption=f"{callback.message.caption}\n\n❌ ОТКЛОНЕНО", - reply_markup=None - ) - except: - pass - - await callback.answer("Оплата отклонена") diff --git a/keyboards/__init__.py b/keyboards/__init__.py deleted file mode 100644 index d63178f..0000000 --- a/keyboards/__init__.py +++ /dev/null @@ -1,150 +0,0 @@ -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from locales import localization - - -class Keyboards: - """Класс для создания клавиатур""" - - @staticmethod - def get_main_keyboard(locale: str = "ru") -> InlineKeyboardMarkup: - """Основная клавиатура бота""" - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=localization.get("buttons.buy_subscription", locale), - callback_data="buy_subscription" - ) - ], - [ - InlineKeyboardButton( - text=localization.get("buttons.support", locale), - callback_data="support" - ), - InlineKeyboardButton( - text=localization.get("buttons.rules", locale), - callback_data="rules" - ) - ], - [ - InlineKeyboardButton( - text=localization.get("buttons.language", locale), - callback_data="language" - ) - ] - ] - ) - - return keyboard - - @staticmethod - def get_buy_subscription_keyboard(locale: str = "ru") -> InlineKeyboardMarkup: - """Клавиатура для покупки подписки""" - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=localization.get("tariffs.14_days", locale), - callback_data="buy_14_days" - ) - ], - [ - InlineKeyboardButton( - text=localization.get("tariffs.30_days", locale), - callback_data="buy_30_days" - ) - ], - [ - InlineKeyboardButton( - text=localization.get("tariffs.60_days", locale), - callback_data="buy_60_days" - ) - ], - [ - InlineKeyboardButton( - text=localization.get("tariffs.90_days", locale), - callback_data="buy_90_days" - ) - ], - [ - InlineKeyboardButton( - text=localization.get("tariffs.180_days", locale), - callback_data="buy_180_days" - ) - ], - [ - InlineKeyboardButton( - text=localization.get("tariffs.360_days", locale), - callback_data="buy_360_days" - ) - ], - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_main" - ) - ] - ] - ) - - return keyboard - - @staticmethod - def get_language_keyboard(locale: str = "ru") -> InlineKeyboardMarkup: - """Клавиатура выбора языка""" - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=localization.get("languages.ru", locale), - callback_data="lang_ru" - ), - InlineKeyboardButton( - text=localization.get("languages.en", locale), - callback_data="lang_en" - ) - ], - [ - InlineKeyboardButton( - text=localization.get("languages.kz", locale), - callback_data="lang_kz" - ) - ], - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_main" - ) - ] - ] - ) - - return keyboard - - @staticmethod - def get_support_keyboard(locale: str = "ru") -> InlineKeyboardMarkup: - """Клавиатура техподдержки""" - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="📝 Создать тикет", - callback_data="support_create_ticket" - ) - ], - [ - InlineKeyboardButton( - text="📋 Мои тикеты", - callback_data="support_my_tickets" - ) - ], - [ - InlineKeyboardButton( - text=localization.get("buttons.back", locale), - callback_data="back_to_main" - ) - ] - ] - ) - - return keyboard diff --git a/keyboards/payment.py b/keyboards/payment.py deleted file mode 100644 index 2cc9e39..0000000 --- a/keyboards/payment.py +++ /dev/null @@ -1,24 +0,0 @@ -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - -class PaymentKeyboards: - """Клавиатуры для оплаты""" - - @staticmethod - def get_payment_approval_keyboard(user_id: int, tariff: str, days: int) -> InlineKeyboardMarkup: - """Клавиатура для подтверждения оплаты (в канал)""" - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="✅ Аппрув", - callback_data=f"payment_approve_{user_id}_{tariff}_{days}" - ), - InlineKeyboardButton( - text="❌ Отклонить", - callback_data=f"payment_decline_{user_id}_{tariff}" - ) - ] - ] - ) - return keyboard diff --git a/locales/__init__.py b/locales/__init__.py deleted file mode 100644 index a8ae6be..0000000 --- a/locales/__init__.py +++ /dev/null @@ -1,91 +0,0 @@ -import json -from pathlib import Path -from typing import Optional - - -class Localization: - """Класс для управления локализацией""" - - _instance = None - _locales = {} - _default_locale = "ru" - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __init__(self): - if not self._locales: - self.load_locales() - - def load_locales(self): - """Загрузка всех файлов локализации""" - locales_dir = Path(__file__).parent - for locale_file in locales_dir.glob("*.json"): - locale_code = locale_file.stem - with open(locale_file, "r", encoding="utf-8") as f: - self._locales[locale_code] = json.load(f) - - def get(self, key: str, locale: str = None, **kwargs) -> str: - """ - Получение локализованной строки - - Args: - key: Ключ строки (например, "welcome", "buttons.buy_subscription") - locale: Код языка (ru, en, kz) - **kwargs: Параметры для форматирования строки - """ - if locale is None: - locale = self._default_locale - - if locale not in self._locales: - locale = self._default_locale - - keys = key.split(".") - value = self._locales.get(locale, {}) - - for k in keys: - if isinstance(value, dict): - value = value.get(k) - else: - value = None - break - - if value is None: - # Пытаемся найти в default locale - value = self._locales.get(self._default_locale, {}) - for k in keys: - if isinstance(value, dict): - value = value.get(k) - else: - value = None - break - - if value is None: - return key - - if kwargs: - try: - value = value.format(**kwargs) - except KeyError: - pass - - return value - - def get_locale_name(self, locale: str) -> str: - """Получение названия языка на родном языке""" - return self.get(f"languages.{locale}", locale) - - def set_default_locale(self, locale: str): - """Установка языка по умолчанию""" - if locale in self._locales: - self._default_locale = locale - - def get_available_locales(self) -> list: - """Получение списка доступных языков""" - return list(self._locales.keys()) - - -# Глобальный экземпляр локализации -localization = Localization() diff --git a/locales/en.json b/locales/en.json deleted file mode 100644 index bbd096a..0000000 --- a/locales/en.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "welcome": "Hello, {username}!", - "subscription_status": "Your subscription: {status}", - "select_action": "Select an action:", - "subscription_active": "Active", - "subscription_inactive": "Inactive", - "buttons": { - "buy_subscription": "💳 Buy Subscription", - "support": "📞 Support", - "rules": "📜 Service Rules", - "language": "🌐 Language", - "back": "🔙 Back" - }, - "messages": { - "support": "Support: {username}", - "support_title": "📞 Support", - "support_description": "This is the ticket center: create requests, view responses and history.\n\n📝 Create ticket - describe your problem or question.\n📋 My tickets - status and correspondence\n\nTry to use tickets - this way we can help faster and nothing gets lost.", - "rules_title": "📜 VPN Rules", - "rules_text": "1. By using the bot, you agree to the rules and use the service lawfully.\n2. VPN is designed to protect data and secure internet access.\n3. Prohibited: hacking, spam, fraud, viruses and illegal content 🚫\n4. User is solely responsible for their actions.\n5. Technical data may be collected (not shared with third parties, except by law).\n6. Access may be restricted without refund for violations.\n\n⚠️ VPN does not guarantee complete anonymity.", - "select_tariff": "Select tariff:", - "tariff_selected": "You selected: {tariff}", - "select_language": "Выберите язык / Select language:", - "language_changed": "Language changed to {language}", - "payment_instruction_title": "Hello! 👋", - "payment_instruction_text": "To purchase a subscription, follow these steps:\n\n1️⃣ Transfer via SBP to: {phone}\n2️⃣ After payment, send the receipt or screenshot to this chat\n\nAfter payment confirmation, we will activate your subscription.\n\nThank you! 😊", - "payment_receipt_sent": "✅ Receipt received! Wait for payment confirmation." - }, - "subscription": { - "standard_title": "Standard Subscription", - "standard_description": "Basic tariff plan" - }, - "tariffs": { - "14_days": "14 days - 150₽", - "30_days": "30 days - 300₽", - "60_days": "60 days - 600₽", - "90_days": "90 days - 900₽", - "180_days": "180 days - 1800₽", - "360_days": "360 days - 3600₽" - }, - "languages": { - "ru": "🇷🇺 Русский", - "en": "🇬🇧 English", - "kz": "🇰🇿 Қазақша" - } -} \ No newline at end of file diff --git a/locales/kz.json b/locales/kz.json deleted file mode 100644 index 1ed13dd..0000000 --- a/locales/kz.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "welcome": "Сәлем, {username}!", - "subscription_status": "Сіздің жазылымыңыз: {status}", - "select_action": "Әрекетті таңдаңыз:", - "subscription_active": "Белсенді", - "subscription_inactive": "Белсенді емес", - "buttons": { - "buy_subscription": "💳 Жазылуды сатып алу", - "support": "📞 Қолдау қызметі", - "rules": "📜 Қызмет ережелері", - "language": "🌐 Тіл", - "back": "🔙 Артқа" - }, - "messages": { - "support": "Қолдау қызметі: {username}", - "support_title": "📞 Қолдау", - "support_description": "Бұл тикет орталығы: өтініштер жасау, жауаптарды және тарихты қарау.\n\n📝 Тикет жасау - мәселеңізді немесе сұрағыңызды сипаттаңыз.\n📋 Менің тикеттерім - мәртебе және хат алмасу\n\nТикеттерді пайдаланыңыз - біз жылдамырақ көмектесеміз және ештеңе жоғалмайды.", - "rules_title": "📜 VPN ережелері", - "rules_text": "1. Ботты пайдалана отырып, сіз ережелермен келісесіз және сервисді заңды түрде пайдаланасыз.\n2. VPN деректерді қорғау және қауіпсіз интернетке қол жеткізу үшін арналған.\n3. Тыйым салынады: бұзу, спам, алаяқтық, вирустар және заңсыз контент 🚫\n4. Пайдаланушы өз әрекеттері үшін дербес жауап береді.\n5. Техникалық деректер жиналуы мүмкін (заңды жағдайларды қоспағанда, үшінші тұлғаларға берілмейді).\n6. Ережелерді бұзған жағдайда қол жеткізу қайтарусыз шектелуі мүмкін.\n\n⚠️ VPN толық анонимділікті кепілдік бермейді.", - "select_tariff": "Тарифті таңдаңыз:", - "tariff_selected": "Сіз таңдадыңыз: {tariff}", - "select_language": "Выберите язык / Select language:", - "language_changed": "Тіл өзгертілді: {language}", - "payment_instruction_title": "Сәлем! 👋", - "payment_instruction_text": "Жазылымды сатып алу үшін мына әрекеттерді орындаңыз:\n\n1️⃣ СБП арқылы {phone} нөміріне аударыңыз\n2️⃣ Төлемнен кейін чекті немесе скриншотты осы чатқа жіберіңіз\n\nТөлем расталғаннан кейін жазылымыңызды белсендіреміз.\n\nРақмет! 😊", - "payment_receipt_sent": "✅ Чек қабылданды! Төлемнің расталуын күтіңіз." - }, - "subscription": { - "standard_title": "Стандартты жазылым", - "standard_description": "Негізгі тарифтік жоспар" - }, - "tariffs": { - "14_days": "14 күн - 150₽", - "30_days": "30 күн - 300₽", - "60_days": "60 күн - 600₽", - "90_days": "90 күн - 900₽", - "180_days": "180 күн - 1800₽", - "360_days": "360 күн - 3600₽" - }, - "languages": { - "ru": "🇷🇺 Русский", - "en": "🇬🇧 English", - "kz": "🇰🇿 Қазақша" - } -} \ No newline at end of file diff --git a/locales/ru.json b/locales/ru.json deleted file mode 100644 index 8a56c5c..0000000 --- a/locales/ru.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "welcome": "Привет, {username}!", - "subscription_status": "Твоя подписка: {status}", - "select_action": "Выберите действие:", - "subscription_active": "Активна", - "subscription_inactive": "Не активна", - "buttons": { - "buy_subscription": "💳 Купить подписку", - "support": "📞 Техподдержка", - "rules": "📜 Правила сервиса", - "language": "🌐 Язык", - "back": "🔙 Назад" - }, - "messages": { - "support": "Техподдержка: {username}", - "support_title": "📞 Поддержка", - "support_description": "Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n📝 Создать тикет - опишите проблему или вопрос.\n📋 Мои тикеты - статус и переписка\n\nСтарайтесь использовать тикеты - так мы быстрее поможем и ничего не потеряется.", - "rules_title": "📜 Правила VPN", - "rules_text": "1. Используя бота, вы соглашаетесь с правилами и используете сервис законно.\n2. VPN предназначен для защиты данных и безопасного доступа к интернету.\n3. Запрещены: взломы, спам, мошенничество, вирусы и незаконный контент 🚫\n4. Пользователь сам несёт ответственность за свои действия.\n5. Возможен сбор технических данных (без передачи третьим лицам, кроме закона).\n6. За нарушения доступ может быть ограничен без возврата средств.\n\n⚠️ VPN не гарантирует полной анонимности.", - "select_tariff": "Выберите тариф:", - "tariff_selected": "Вы выбрали тариф: {tariff}", - "select_language": "Выберите язык / Select language:", - "language_changed": "Язык изменён на {language}", - "payment_instruction_title": "Здравствуйте! 👋", - "payment_instruction_text": "Чтобы приобрести подписку, выполните следующие шаги:\n\n1️⃣ Переведите сумму через СБП по номеру: {phone}\n2️⃣ После оплаты отправьте чек или скриншот перевода в этот чат\n\nПосле подтверждения оплаты мы активируем вашу подписку.\n\nСпасибо! 😊", - "payment_receipt_sent": "✅ Чек получен! Ожидайте подтверждения оплаты." - }, - "subscription": { - "standard_title": "Стандартная подписка", - "standard_description": "Базовый тарифный план" - }, - "tariffs": { - "14_days": "14 дней - 150₽", - "30_days": "30 дней - 300₽", - "60_days": "60 дней - 600₽", - "90_days": "90 дней - 900₽", - "180_days": "180 дней - 1800₽", - "360_days": "360 дней - 3600₽" - }, - "languages": { - "ru": "🇷🇺 Русский", - "en": "🇬🇧 English", - "kz": "🇰🇿 Қазақша" - } -} \ No newline at end of file diff --git a/main.py b/main.py index 140e94d..40e2843 100644 --- a/main.py +++ b/main.py @@ -1,40 +1,6 @@ import asyncio -import logging -from aiogram import Bot, Dispatcher -from aiogram.enums import ParseMode -from aiogram.client.default import DefaultBotProperties - -from config import config -from core import db -from handlers import main_router, admin_router - -# Настройка логирования -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) - -# Инициализация бота и диспетчера -bot = Bot(token=config.bot_token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) -dp = Dispatcher() - -# Регистрация роутеров -dp.include_router(main_router) -dp.include_router(admin_router) - - -async def on_startup(): - """Инициализация при запуске""" - # Создаём БД и таблицы если нет - await db.initialize() - print("Бот запущен...") - - -async def main(): - """Запуск бота""" - await on_startup() - await dp.start_polling(bot) +from app.main import main if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0ec1f9f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling>=1.27.0"] +build-backend = "hatchling.build" + +[project] +name = "telegabot" +version = "0.1.0" +description = "Telegram bot on aiogram with MariaDB and Remnawave API integration" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "aiogram>=3.20.0,<4.0.0", + "aiohttp-socks>=0.10.1,<0.11.0", + "aiomysql>=0.2.0,<0.3.0", + "httpx>=0.28.0,<0.29.0", + "pydantic-settings>=2.7.0,<3.0.0", + "python-dotenv>=1.0.1,<2.0.0", + "SQLAlchemy>=2.0.36,<3.0.0", +] + +[project.optional-dependencies] +dev = [ + "aiosqlite>=0.21.0,<1.0.0", + "pytest>=8.3.4,<9.0.0", + "pytest-asyncio>=0.25.3,<1.0.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.hatch.build.targets.wheel] +packages = ["app"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 3317c8f..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -aiogram==3.3.0 -mysql-connector-python==8.3.0 -python-dotenv==1.0.1 -aiohttp==3.9.3 diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..ba4782e --- /dev/null +++ b/run.bat @@ -0,0 +1,126 @@ +@echo off +setlocal EnableExtensions + +set "SCRIPT_DIR=%~dp0" +pushd "%SCRIPT_DIR%" >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Failed to switch to project directory: + echo %SCRIPT_DIR% + pause + exit /b 1 +) + +if not exist ".env" ( + echo [ERROR] .env file was not found. + echo Copy .env.example to .env and fill in BOT_TOKEN, REMNAWAVE_* and DB_* values. + popd + pause + exit /b 1 +) + +call :find_python +if errorlevel 1 ( + echo [ERROR] Python was not found in PATH. + echo Install Python 3.12+ and make sure ^`py^` or ^`python^` is available. + popd + pause + exit /b 1 +) + +call :ensure_venv +if errorlevel 1 ( + popd + pause + exit /b 1 +) + +echo [INFO] Installing dependencies... +".venv\Scripts\python.exe" -m pip install -e . +if errorlevel 1 ( + echo [ERROR] Failed to install dependencies + popd + pause + exit /b 1 +) + +echo [INFO] Starting bot... +".venv\Scripts\python.exe" main.py +set "EXIT_CODE=%ERRORLEVEL%" + +if not "%EXIT_CODE%"=="0" ( + echo [ERROR] Bot stopped with code %EXIT_CODE% + popd + pause + exit /b %EXIT_CODE% +) + +popd +exit /b 0 + +:find_python +set "BOOTSTRAP_PY=" + +py -3 --version >nul 2>&1 +if not errorlevel 1 ( + set "BOOTSTRAP_PY=py -3" + echo [INFO] Using bootstrap interpreter: %BOOTSTRAP_PY% + exit /b 0 +) + +py --version >nul 2>&1 +if not errorlevel 1 ( + set "BOOTSTRAP_PY=py" + echo [INFO] Using bootstrap interpreter: %BOOTSTRAP_PY% + exit /b 0 +) + +python --version >nul 2>&1 +if not errorlevel 1 ( + set "BOOTSTRAP_PY=python" + echo [INFO] Using bootstrap interpreter: %BOOTSTRAP_PY% + exit /b 0 +) + +exit /b 1 + +:ensure_venv +set "RECREATE_VENV=" + +if not exist ".venv\Scripts\python.exe" ( + set "RECREATE_VENV=1" +) + +if not defined RECREATE_VENV ( + ".venv\Scripts\python.exe" -c "import sys; print(sys.executable)" >nul 2>&1 + if errorlevel 1 ( + set "RECREATE_VENV=1" + ) +) + +if defined RECREATE_VENV ( + if exist ".venv" ( + echo [WARN] Existing .venv is invalid or was created on another machine. + echo [INFO] Recreating virtual environment... + rmdir /s /q ".venv" + if exist ".venv" ( + echo [ERROR] Failed to remove broken .venv + exit /b 1 + ) + ) else ( + echo [INFO] Creating virtual environment... + ) + + %BOOTSTRAP_PY% -m venv .venv + if errorlevel 1 ( + echo [ERROR] Failed to create .venv + exit /b 1 + ) +) + +".venv\Scripts\python.exe" -m pip --version >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Virtual environment exists, but pip is unavailable. + exit /b 1 +) + +exit /b 0 diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..ad6957a --- /dev/null +++ b/sql/schema.sql @@ -0,0 +1,173 @@ +CREATE TABLE IF NOT EXISTS telegram_users ( + id INT AUTO_INCREMENT PRIMARY KEY, + telegram_id BIGINT NOT NULL UNIQUE, + username VARCHAR(64) NULL, + first_name VARCHAR(255) NULL, + last_name VARCHAR(255) NULL, + language_code VARCHAR(16) NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + is_blocked BOOLEAN NOT NULL DEFAULT FALSE, + last_seen_at DATETIME NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX ix_telegram_users_telegram_id (telegram_id) +); + +CREATE TABLE IF NOT EXISTS remnawave_users ( + id INT AUTO_INCREMENT PRIMARY KEY, + owner_telegram_user_id INT NULL, + rw_uuid CHAR(36) NOT NULL UNIQUE, + rw_id BIGINT NOT NULL UNIQUE, + short_uuid VARCHAR(48) NOT NULL UNIQUE, + username VARCHAR(36) NOT NULL UNIQUE, + status VARCHAR(16) NOT NULL, + traffic_limit_bytes BIGINT NOT NULL DEFAULT 0, + used_traffic_bytes DOUBLE NOT NULL DEFAULT 0, + lifetime_used_traffic_bytes DOUBLE NOT NULL DEFAULT 0, + expire_at DATETIME NOT NULL, + telegram_id BIGINT NULL, + email VARCHAR(320) NULL, + description TEXT NULL, + tag VARCHAR(16) NULL, + hwid_device_limit INT NULL, + external_squad_uuid CHAR(36) NULL, + trojan_password VARCHAR(64) NOT NULL, + vless_uuid CHAR(36) NOT NULL, + ss_password VARCHAR(64) NOT NULL, + last_triggered_threshold INT NOT NULL DEFAULT 0, + sub_revoked_at DATETIME NULL, + sub_last_user_agent VARCHAR(512) NULL, + sub_last_opened_at DATETIME NULL, + last_traffic_reset_at DATETIME NULL, + subscription_url TEXT NOT NULL, + online_at DATETIME NULL, + first_connected_at DATETIME NULL, + last_connected_node_uuid CHAR(36) NULL, + remote_created_at DATETIME NOT NULL, + remote_updated_at DATETIME NOT NULL, + synced_at DATETIME NOT NULL, + CONSTRAINT fk_remnawave_users_owner + FOREIGN KEY (owner_telegram_user_id) + REFERENCES telegram_users (id) + ON DELETE SET NULL, + INDEX ix_remnawave_users_rw_uuid (rw_uuid), + INDEX ix_remnawave_users_short_uuid (short_uuid), + INDEX ix_remnawave_users_username (username), + INDEX ix_remnawave_users_telegram_id (telegram_id), + INDEX ix_remnawave_users_email (email), + INDEX ix_remnawave_users_tag (tag) +); + +CREATE TABLE IF NOT EXISTS internal_squads ( + uuid CHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + synced_at DATETIME NOT NULL +); + +CREATE TABLE IF NOT EXISTS remnawave_user_internal_squads ( + user_id INT NOT NULL, + squad_uuid CHAR(36) NOT NULL, + PRIMARY KEY (user_id, squad_uuid), + CONSTRAINT fk_rw_user_squad_user + FOREIGN KEY (user_id) + REFERENCES remnawave_users (id) + ON DELETE CASCADE, + CONSTRAINT fk_rw_user_squad_squad + FOREIGN KEY (squad_uuid) + REFERENCES internal_squads (uuid) + ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS subscription_request_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + remote_id BIGINT NOT NULL UNIQUE, + user_uuid CHAR(36) NOT NULL, + request_at DATETIME NOT NULL, + request_ip VARCHAR(45) NULL, + user_agent TEXT NULL, + synced_at DATETIME NOT NULL, + CONSTRAINT fk_subscription_request_logs_user + FOREIGN KEY (user_uuid) + REFERENCES remnawave_users (rw_uuid) + ON DELETE CASCADE, + INDEX ix_subscription_request_logs_user_uuid (user_uuid), + INDEX ix_subscription_request_logs_request_at (request_at) +); + +CREATE TABLE IF NOT EXISTS support_tickets ( + id INT AUTO_INCREMENT PRIMARY KEY, + public_id VARCHAR(32) NOT NULL UNIQUE, + telegram_id BIGINT NOT NULL, + username VARCHAR(64) NULL, + display_name VARCHAR(255) NOT NULL, + user_message TEXT NOT NULL, + support_chat_id BIGINT NOT NULL, + support_thread_id INT NULL, + support_message_id BIGINT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'OPEN', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY ix_support_tickets_chat_message (support_chat_id, support_message_id), + INDEX ix_support_tickets_public_id (public_id), + INDEX ix_support_tickets_telegram_id (telegram_id), + INDEX ix_support_tickets_support_chat_id (support_chat_id) +); + +CREATE TABLE IF NOT EXISTS referral_invites ( + id INT AUTO_INCREMENT PRIMARY KEY, + inviter_telegram_id BIGINT NOT NULL, + invited_telegram_id BIGINT NOT NULL UNIQUE, + invited_username VARCHAR(64) NULL, + invited_display_name VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL, + INDEX ix_referral_invites_inviter_telegram_id (inviter_telegram_id), + INDEX ix_referral_invites_invited_telegram_id (invited_telegram_id) +); + +CREATE TABLE IF NOT EXISTS referral_codes ( + id INT AUTO_INCREMENT PRIMARY KEY, + telegram_id BIGINT NOT NULL UNIQUE, + code VARCHAR(32) NOT NULL UNIQUE, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX ix_referral_codes_telegram_id (telegram_id), + INDEX ix_referral_codes_code (code) +); + +CREATE TABLE IF NOT EXISTS payment_orders ( + id INT AUTO_INCREMENT PRIMARY KEY, + order_uuid CHAR(36) NOT NULL UNIQUE, + telegram_user_id INT NOT NULL, + telegram_id BIGINT NOT NULL, + plan_code VARCHAR(64) NOT NULL, + plan_title VARCHAR(255) NOT NULL, + plan_duration_days INT NOT NULL, + traffic_limit_bytes BIGINT NOT NULL DEFAULT 0, + traffic_limit_strategy VARCHAR(32) NOT NULL DEFAULT 'NO_RESET', + amount_stars INT NOT NULL, + currency VARCHAR(8) NOT NULL DEFAULT 'XTR', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + invoice_payload VARCHAR(128) NOT NULL UNIQUE, + provision_username VARCHAR(36) NOT NULL, + remnawave_user_uuid CHAR(36) NULL, + subscription_url TEXT NULL, + telegram_payment_charge_id VARCHAR(255) NULL UNIQUE, + provider_payment_charge_id VARCHAR(255) NULL, + error_message TEXT NULL, + paid_at DATETIME NULL, + fulfilled_at DATETIME NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + CONSTRAINT fk_payment_orders_telegram_user + FOREIGN KEY (telegram_user_id) + REFERENCES telegram_users (id) + ON DELETE CASCADE, + INDEX ix_payment_orders_order_uuid (order_uuid), + INDEX ix_payment_orders_telegram_user_id (telegram_user_id), + INDEX ix_payment_orders_telegram_id (telegram_id), + INDEX ix_payment_orders_plan_code (plan_code), + INDEX ix_payment_orders_status (status), + INDEX ix_payment_orders_invoice_payload (invoice_payload), + INDEX ix_payment_orders_provision_username (provision_username), + INDEX ix_payment_orders_remnawave_user_uuid (remnawave_user_uuid) +); diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..29b4962 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +from collections.abc import AsyncIterator + +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.db.base import Base + + +@pytest_asyncio.fixture +async def session_factory(tmp_path) -> AsyncIterator[async_sessionmaker[AsyncSession]]: + database_path = tmp_path / "test.db" + engine = create_async_engine(f"sqlite+aiosqlite:///{database_path}") + + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + + factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + try: + yield factory + finally: + await engine.dispose() diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..d5fa0b4 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from app.schemas.remnawave import RemnawaveUser + + +def make_remote_user( + *, + user_uuid: str, + user_id: int, + short_uuid: str, + username: str, + telegram_id: int | None, + expire_at: datetime | None = None, + traffic_limit_bytes: int = 0, +) -> RemnawaveUser: + now = datetime.now(timezone.utc).replace(microsecond=0) + expire = expire_at or (now + timedelta(days=30)) + + return RemnawaveUser.model_validate( + { + "uuid": user_uuid, + "id": user_id, + "shortUuid": short_uuid, + "username": username, + "status": "ACTIVE", + "trafficLimitBytes": traffic_limit_bytes, + "expireAt": expire.isoformat().replace("+00:00", "Z"), + "telegramId": telegram_id, + "email": None, + "description": None, + "tag": "BOT", + "hwidDeviceLimit": None, + "externalSquadUuid": None, + "trojanPassword": f"trojan-{user_id}", + "vlessUuid": f"00000000-0000-0000-0000-{user_id:012d}", + "ssPassword": f"ss-{user_id}", + "lastTriggeredThreshold": 0, + "subRevokedAt": None, + "subLastUserAgent": None, + "subLastOpenedAt": None, + "lastTrafficResetAt": None, + "createdAt": now.isoformat().replace("+00:00", "Z"), + "updatedAt": now.isoformat().replace("+00:00", "Z"), + "subscriptionUrl": f"https://example.com/sub/{short_uuid}", + "activeInternalSquads": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "name": "VPN", + } + ], + "userTraffic": { + "usedTrafficBytes": 0, + "lifetimeUsedTrafficBytes": 0, + "onlineAt": None, + "firstConnectedAt": None, + "lastConnectedNodeUuid": None, + }, + } + ) diff --git a/tests/test_bot_config_service.py b/tests/test_bot_config_service.py new file mode 100644 index 0000000..8d5b820 --- /dev/null +++ b/tests/test_bot_config_service.py @@ -0,0 +1,74 @@ +from app.config import Settings +from app.services.bot_config_service import BotConfigService + + +def _make_settings() -> Settings: + return Settings.model_construct( + bot_brand_name="OREOL VPN", + bot_public_username="oreol_vpn_bot", + bot_support_url="https://t.me/support_account", + bot_terms_url="https://example.com/terms", + bot_support_ticket_link="", + bot_support_ticket_chat_id_raw="", + bot_support_ticket_thread_id=0, + bot_start_image_enabled=True, + bot_start_image_path="assets/main.png", + payment_review_link="", + payment_review_chat_id_raw="", + payment_review_thread_id=0, + referral_discount_percent=5, + referral_bonus_days=7, + payment_plans_raw="30:250,180:600", + payment_transfer_text="Реквизиты", + payment_support_text="Напишите в поддержку", + payment_plan_traffic_limit_gb=0, + payment_plan_traffic_reset_period="NO_RESET", + payment_internal_squad_uuids_raw="uuid-1", + payment_external_squad_uuid="", + payment_username_prefix="Oreol", + payment_user_tag="BOT", + ) + + +async def test_bot_config_service_updates_runtime_snapshot(session_factory) -> None: + service = BotConfigService( + settings=_make_settings(), + session_factory=session_factory, + ) + + await service.update_setting( + key="bot_brand_name", + raw_value="NEW BRAND", + updated_by_telegram_id=1, + ) + await service.update_setting( + key="payment_plans_raw", + raw_value="30:300,90:700", + updated_by_telegram_id=1, + ) + await service.update_setting( + key="referral_enabled", + raw_value="off", + updated_by_telegram_id=1, + ) + + snapshot = await service.get_snapshot() + + assert snapshot.bot_brand_name == "NEW BRAND" + assert snapshot.referral_enabled is False + assert [(plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [ + (30, 300), + (90, 700), + ] + + +async def test_bot_config_service_formats_values_for_admin_ui(session_factory) -> None: + service = BotConfigService( + settings=_make_settings(), + session_factory=session_factory, + ) + snapshot = await service.get_snapshot() + + assert BotConfigService.format_value(snapshot=snapshot, key="bot_public_username") == "@oreol_vpn_bot" + assert BotConfigService.format_value(snapshot=snapshot, key="referral_enabled") == "on" + assert BotConfigService.format_value(snapshot=snapshot, key="payment_plan_traffic_limit_gb") == "0 GB (unlimited)" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..997f806 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,81 @@ +from app.config import Settings + + +def test_database_server_url_excludes_database_name() -> None: + settings = Settings.model_construct( + db_host="127.0.0.1", + db_port=3306, + db_name="oreolvpn", + db_user="user", + db_password="pass", + ) + + assert settings.database_server_url == "mysql+aiomysql://user:pass@127.0.0.1:3306/?charset=utf8mb4" + + +def test_support_ticket_link_parses_private_topic_link() -> None: + settings = Settings.model_construct( + bot_support_ticket_link="https://t.me/c/3646494169/2/3", + bot_support_ticket_chat_id_raw="", + bot_support_ticket_thread_id=0, + ) + + assert settings.support_ticket_chat_id == -1003646494169 + assert settings.support_ticket_message_thread_id == 2 + + +def test_payment_review_link_parses_private_topic_link() -> None: + settings = Settings.model_construct( + payment_review_link="https://t.me/c/3646494169/56/57", + payment_review_chat_id_raw="", + payment_review_thread_id=0, + ) + + assert settings.payment_review_chat_id == -1003646494169 + assert settings.payment_review_message_thread_id == 56 + + +def test_moderator_ids_are_parsed() -> None: + settings = Settings.model_construct( + bot_moderator_ids_raw="123, 456 ,789", + ) + + assert settings.bot_moderator_ids == {123, 456, 789} + assert settings.is_moderator(456) is True + assert settings.is_moderator(999) is False + + +def test_public_username_is_normalized() -> None: + settings = Settings.model_construct( + bot_public_username="@oreol_vpn_bot", + ) + + assert settings.bot_public_username_normalized == "oreol_vpn_bot" + + +def test_payment_internal_squad_uuids_are_parsed() -> None: + settings = Settings.model_construct( + payment_internal_squad_uuids_raw="uuid-1, uuid-2 ,uuid-3", + ) + + assert settings.payment_internal_squad_uuids == ["uuid-1", "uuid-2", "uuid-3"] + + +def test_payment_username_prefix_is_normalized() -> None: + settings = Settings.model_construct( + payment_username_prefix="Oreol!! prefix", + ) + + assert settings.payment_username_prefix_normalized == "Oreolprefix" + + +def test_payment_plans_are_parsed() -> None: + settings = Settings.model_construct( + payment_plans_raw="30:250,180:600,365:1000", + ) + + plans = settings.payment_plans + + assert [plan.code for plan in plans] == ["30d", "180d", "365d"] + assert [plan.days for plan in plans] == [30, 180, 365] + assert [plan.amount_rub for plan in plans] == [250, 600, 1000] diff --git a/tests/test_db_session.py b/tests/test_db_session.py new file mode 100644 index 0000000..31db940 --- /dev/null +++ b/tests/test_db_session.py @@ -0,0 +1,77 @@ +import pytest + +from app.db.session import _normalize_payment_order_provision_username_index + + +class _FakeResult: + def __init__(self, rows): + self._rows = rows + + def __iter__(self): + return iter(self._rows) + + +class _FakeConnection: + def __init__(self, *, dialect_name: str, responses: list[list[tuple[str]]]) -> None: + self.dialect = type("Dialect", (), {"name": dialect_name})() + self._responses = list(responses) + self.executed: list[tuple[str, dict[str, object] | None]] = [] + + async def execute(self, statement, params=None): + self.executed.append((str(statement), params)) + rows = self._responses.pop(0) if self._responses else [] + return _FakeResult(rows) + + +@pytest.mark.asyncio +async def test_normalize_payment_order_index_drops_stale_unique_and_recreates_plain_index() -> None: + connection = _FakeConnection( + dialect_name="mysql", + responses=[ + [("ix_payment_orders_provision_username",)], + [], + [], + [], + ], + ) + + await _normalize_payment_order_provision_username_index(connection) + + executed_sql = [sql for sql, _ in connection.executed] + + assert any( + "DROP INDEX `ix_payment_orders_provision_username` ON `payment_orders`" in sql + for sql in executed_sql + ) + assert any( + "CREATE INDEX `ix_payment_orders_provision_username` ON `payment_orders` (`provision_username`)" + in sql + for sql in executed_sql + ) + + +@pytest.mark.asyncio +async def test_normalize_payment_order_index_keeps_existing_non_unique_index() -> None: + connection = _FakeConnection( + dialect_name="mysql", + responses=[ + [], + [("custom_payment_orders_provision_username",)], + ], + ) + + await _normalize_payment_order_provision_username_index(connection) + + executed_sql = [sql for sql, _ in connection.executed] + + assert not any("DROP INDEX" in sql for sql in executed_sql) + assert not any("CREATE INDEX" in sql for sql in executed_sql) + + +@pytest.mark.asyncio +async def test_normalize_payment_order_index_skips_non_mysql_backends() -> None: + connection = _FakeConnection(dialect_name="sqlite", responses=[]) + + await _normalize_payment_order_provision_username_index(connection) + + assert connection.executed == [] diff --git a/tests/test_formatters.py b/tests/test_formatters.py new file mode 100644 index 0000000..8065512 --- /dev/null +++ b/tests/test_formatters.py @@ -0,0 +1,88 @@ +from datetime import datetime, timedelta + +from app.utils.formatters import ( + build_help_text, + format_bytes, + format_datetime, + format_datetime_with_days_left, + format_issued_access, + format_payment_plan, + format_traffic_limit, +) + + +def test_format_bytes_for_megabytes() -> None: + assert format_bytes(1024 * 1024) == "1.00 MB" + + +def test_format_traffic_limit_without_limit() -> None: + assert format_traffic_limit(0) == "без лимита" + + +def test_format_datetime_value() -> None: + assert format_datetime(datetime(2026, 4, 21, 12, 30)) == "21.04.2026 12:30" + + +def test_build_help_text_contains_only_start_command() -> None: + text = build_help_text(is_admin=False) + + assert "/start - открыть главное меню" in text + assert "Дальше используйте кнопки внутри интерфейса бота." in text + assert "/menu - " not in text + assert "/buy - " not in text + assert "/paysupport - " not in text + assert "/sync - " not in text + assert "/link " not in text + assert "/help - " not in text + + +def test_format_datetime_with_days_left() -> None: + value = datetime.now().replace(microsecond=0) + timedelta(days=3, hours=2) + + text = format_datetime_with_days_left(value) + + assert value.strftime("%d.%m.%Y %H:%M") in text + assert "(4 " in text + + +def test_format_payment_plan_uses_rubles() -> None: + text = format_payment_plan( + title="OREOL VPN на 30 дней", + description="Доступ к VPN на 30 дней", + price_stars=250, + duration_days=30, + traffic_limit_bytes=0, + ) + + assert "OREOL VPN на 30 дней" in text + assert "Цена: 250 ₽" in text + assert "Трафик: без лимита" in text + + +def test_format_issued_access_contains_subscription_data() -> None: + text = format_issued_access( + subscription_url="https://example.com/sub", + expire_at=datetime(2026, 5, 21, 18, 0), + traffic_limit_bytes=0, + brand_name="OREOL VPN", + ) + + assert "OREOL VPN" in text + assert "https://example.com/sub" in text + assert "21.05.2026 18:00" in text + assert "Подписка активирована" in text + assert "Что делать дальше" in text + assert "/me
" not in text + + +def test_format_issued_access_for_renewal_mentions_extension() -> None: + text = format_issued_access( + subscription_url="https://example.com/sub", + expire_at=datetime(2026, 5, 21, 18, 0), + traffic_limit_bytes=0, + brand_name="OREOL VPN", + is_renewal=True, + ) + + assert "подписка продлена" in text.lower() + assert "Подписка продлена." in text diff --git a/tests/test_panel_ui.py b/tests/test_panel_ui.py new file mode 100644 index 0000000..97f289e --- /dev/null +++ b/tests/test_panel_ui.py @@ -0,0 +1,472 @@ +from datetime import datetime, timedelta +from types import SimpleNamespace + +from app.bot.ui.panel import ( + PANEL_SECTION_ADMIN, + PANEL_SECTION_HOME, + PANEL_SECTION_REFERRAL, + PANEL_SECTION_SUBSCRIPTION, + PANEL_SECTION_SUPPORT, + PANEL_SECTION_TERMS, + PanelContext, + build_panel_keyboard, + render_panel_caption, +) +from app.services.sync_service import CachedUserView + + +def _button_texts(markup) -> list[str]: + return [button.text for row in markup.inline_keyboard for button in row] + + +def _cached_user_view(*, telegram_id: int = 123456789) -> CachedUserView: + return CachedUserView( + record=SimpleNamespace( + username="oreol-renew", + status="ACTIVE", + expire_at=datetime.now() + timedelta(days=10), + used_traffic_bytes=0, + traffic_limit_bytes=0, + short_uuid="renew999", + subscription_url="https://example.com/sub/renew999", + telegram_id=telegram_id, + ), + internal_squads=["VPN"], + ) + + +def test_home_panel_caption_without_subscription() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[], + api_error=None, + ) + + caption = render_panel_caption( + section=PANEL_SECTION_HOME, + context=context, + brand_name="OREOL VPN", + ) + + assert "Добро пожаловать в OREOL VPN" in caption + assert "Профиль" not in caption + assert "Подписка" in caption + assert "Статус: Не активна" in caption + assert "Срок: " in caption + assert "Трафик: " in caption + + +def test_home_panel_keyboard_has_expected_buttons() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[], + api_error=None, + ) + + markup = build_panel_keyboard(section=PANEL_SECTION_HOME, context=context) + button_texts = _button_texts(markup) + + assert "Профиль" not in button_texts + assert "💳 Подписка" in button_texts + assert "🆘 Поддержка" in button_texts + assert "👥 Реферальная система" in button_texts + assert "📜 Условия использования" in button_texts + assert len(markup.inline_keyboard[0]) == 1 + assert markup.inline_keyboard[0][0].text == "💳 Подписка" + assert markup.inline_keyboard[0][0].callback_data == "payment:menu" + + +def test_admin_home_panel_keyboard_has_admin_button() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=True, + users=[], + api_error=None, + ) + + markup = build_panel_keyboard(section=PANEL_SECTION_HOME, context=context) + button_texts = _button_texts(markup) + + assert "🛠 Админ-панель" in button_texts + assert len(markup.inline_keyboard[1]) == 1 + assert markup.inline_keyboard[1][0].text == "🛠 Админ-панель" + assert markup.inline_keyboard[1][0].callback_data == "panel:admin" + + +def test_admin_panel_caption_contains_dashboard_stats() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=True, + users=[], + admin_total_telegram_users=120, + admin_total_cached_users=80, + admin_active_cached_users=67, + admin_pending_orders=9, + admin_review_orders=4, + admin_open_tickets=3, + admin_pending_referral_bonuses=2, + api_error=None, + ) + + caption = render_panel_caption( + section=PANEL_SECTION_ADMIN, + context=context, + brand_name="OREOL VPN", + ) + + assert "🛠 Админ-панель OREOL VPN" in caption + assert "Пользователей в боте: 120" in caption + assert "Чеков на проверке: 4" in caption + assert "Открытых тикетов: 3" in caption + assert "Бонусов в ожидании: 2" in caption + + +def test_admin_panel_keyboard_has_expected_buttons() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=True, + users=[], + api_error=None, + ) + + markup = build_panel_keyboard( + section=PANEL_SECTION_ADMIN, + context=context, + payment_review_url="https://t.me/c/123/56/57", + support_ticket_url="https://t.me/c/123/2/3", + ) + button_texts = _button_texts(markup) + + assert "🔍 Найти пользователя" in button_texts + assert "👤 Пользователи" in button_texts + assert "🔄 Полная синхронизация" in button_texts + assert "💸 Очередь оплат" in button_texts + assert "🎫 Тикеты" in button_texts + assert "↻ Обновить" in button_texts + assert "🏠 Главное меню" in button_texts + + +def test_subscription_caption_without_subscription_but_with_payment() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[], + payment_enabled=True, + payment_plan_title="OREOL VPN на 30 дней", + payment_plan_description="Доступ к VPN на 30 дней", + payment_plan_price_stars=250, + payment_plan_duration_days=30, + payment_plan_traffic_limit_bytes=0, + api_error=None, + ) + + caption = render_panel_caption( + section=PANEL_SECTION_SUBSCRIPTION, + context=context, + brand_name="OREOL VPN", + ) + + assert "У вас пока нет активного доступа." in caption + assert "OREOL VPN на 30 дней" in caption + assert "Цена: 250 ₽" in caption + assert "После выбора тарифа бот покажет реквизиты" in caption + + +def test_subscription_caption_without_payment_uses_ui_instructions() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[], + payment_enabled=False, + api_error=None, + ) + + caption = render_panel_caption( + section=PANEL_SECTION_SUBSCRIPTION, + context=context, + brand_name="OREOL VPN", + ) + + assert "откройте поддержку" in caption + assert "short UUID" in caption + assert "/link" not in caption + + +def test_subscription_keyboard_has_buy_button() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[], + payment_enabled=True, + api_error=None, + ) + + markup = build_panel_keyboard(section=PANEL_SECTION_SUBSCRIPTION, context=context) + button_texts = _button_texts(markup) + + assert "💳 Купить подписку" in button_texts + assert "🏠 Главное меню" in button_texts + + +def test_subscription_keyboard_has_renew_button_when_access_exists() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[_cached_user_view()], + payment_enabled=True, + api_error=None, + ) + + markup = build_panel_keyboard(section=PANEL_SECTION_SUBSCRIPTION, context=context) + button_texts = _button_texts(markup) + + assert "💳 Продлить подписку" in button_texts + assert "💳 Купить подписку" not in button_texts + + +def test_subscription_caption_with_existing_access_mentions_renewal() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[_cached_user_view()], + payment_enabled=True, + api_error=None, + ) + + caption = render_panel_caption( + section=PANEL_SECTION_SUBSCRIPTION, + context=context, + brand_name="OREOL VPN", + ) + + assert "Продлить подписку" in caption + assert "автоматически добавит выбранный срок" in caption + + +def test_terms_panel_caption() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[], + api_error=None, + ) + + caption = render_panel_caption( + section=PANEL_SECTION_TERMS, + context=context, + brand_name="OREOL VPN", + ) + + assert "📜 Правила VPN" in caption + assert "использовать VPN только в законных целях" in caption + assert "не гарантирует полной анонимности" in caption + + +def test_support_panel_has_create_ticket_button() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[], + api_error=None, + ) + + markup = build_panel_keyboard(section=PANEL_SECTION_SUPPORT, context=context) + button_texts = _button_texts(markup) + + assert "🎫 Создать тикет" in button_texts + assert "🏠 Главное меню" in button_texts + + +def test_support_panel_has_support_url_button() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[], + api_error=None, + ) + + markup = build_panel_keyboard( + section=PANEL_SECTION_SUPPORT, + context=context, + support_url="https://t.me/support_account", + ) + button_texts = _button_texts(markup) + + assert "Написать в поддержку" in button_texts + + +def test_support_panel_caption() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[], + api_error=None, + ) + + caption = render_panel_caption( + section=PANEL_SECTION_SUPPORT, + context=context, + brand_name="OREOL VPN", + ) + + assert "🆘 Помощь и сопровождение" in caption + assert "откройте тикет через кнопку ниже" in caption + + +def test_referral_panel_caption_with_link() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[], + referral_code="ABCD1234", + referral_count=3, + referral_link="https://t.me/oreol_vpn_bot?start=ref_ABCD1234", + referral_share_url="https://t.me/share/url?url=test", + referral_recent_names=["Alex", "Maria"], + referral_discount_percent=12, + referral_bonus_days=7, + api_error=None, + ) + + caption = render_panel_caption( + section=PANEL_SECTION_REFERRAL, + context=context, + brand_name="OREOL VPN", + ) + + assert "👥 Реферальная система" in caption + assert "Ваш реферальный код: ABCD1234" in caption + assert "Приглашено пользователей: 3" in caption + assert "+7 дней" in caption + assert "Чтобы получить скидку 12%" in caption + assert "https://t.me/oreol_vpn_bot?start=ref_ABCD1234" in caption + assert "Alex" in caption + assert "Maria" in caption + + +def test_referral_caption_uses_runtime_discount_for_applied_code() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[], + referral_code="ABCD1234", + referral_count=1, + applied_referral_code="PROMO777", + referral_discount_percent=17, + api_error=None, + ) + + caption = render_panel_caption( + section=PANEL_SECTION_REFERRAL, + context=context, + brand_name="OREOL VPN", + ) + + assert "Код: PROMO777" in caption + assert "Скидка перед оплатой: 17%" in caption + + +def test_admin_panel_keyboard_has_settings_button() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=True, + users=[], + api_error=None, + ) + + markup = build_panel_keyboard(section=PANEL_SECTION_ADMIN, context=context) + button_texts = _button_texts(markup) + + assert any("Настрой" in text for text in button_texts) + + +def test_home_panel_hides_referral_when_disabled() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[], + referral_enabled=False, + api_error=None, + ) + + markup = build_panel_keyboard(section=PANEL_SECTION_HOME, context=context) + button_texts = _button_texts(markup) + + assert all("Реферал" not in text for text in button_texts) + + +def test_referral_panel_caption_when_disabled() -> None: + context = PanelContext( + telegram_id=123456789, + display_name="Evgeniy Spirin", + username="evgeniy", + language_code="ru", + is_admin=False, + users=[], + referral_enabled=False, + api_error=None, + ) + + caption = render_panel_caption( + section=PANEL_SECTION_REFERRAL, + context=context, + brand_name="OREOL VPN", + ) + + assert "отключена" in caption diff --git a/tests/test_payment_service.py b/tests/test_payment_service.py new file mode 100644 index 0000000..c87cad6 --- /dev/null +++ b/tests/test_payment_service.py @@ -0,0 +1,484 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from app.config import Settings +from app.db.models import PaymentOrder, ReferralBonus, ReferralInvite, TelegramUser +from app.services.payment_service import ( + FULFILLED_PAYMENT_STATUS, + PENDING_PAYMENT_STATUS, + REFERRAL_BONUS_APPLIED_STATUS, + REFERRAL_BONUS_PENDING_STATUS, + REVIEW_PAYMENT_STATUS, + PaymentService, +) +from app.services.remnawave_client import RemnawaveApiError +from tests.helpers import make_remote_user + + +class _StubSyncService: + def __init__(self, *, cached_users_by_telegram: dict[int, list] | None = None) -> None: + self.refreshed: list[int] = [] + self.cached_users_by_telegram = cached_users_by_telegram or {} + + async def get_referral_summary(self, telegram_id: int): + return type( + "ReferralSummaryStub", + (), + { + "applied_referral_code": "", + "referral_code": "", + "total_invited": 0, + "recent_names": [], + }, + )() + + async def refresh_cached_users_for_telegram(self, *, telegram_id: int): + self.refreshed.append(telegram_id) + return [] + + async def get_cached_users_for_telegram(self, telegram_id: int): + return list(self.cached_users_by_telegram.get(telegram_id, [])) + + +class _StubRemnawaveClient: + def __init__(self, *, created_user, users_by_telegram_id: dict[int, list] | None = None) -> None: + self.created_user = created_user + self.created_bodies: list[dict[str, object]] = [] + self.updated_bodies: list[dict[str, object]] = [] + self.users_by_telegram_id = users_by_telegram_id or {} + + async def get_user_by_uuid(self, user_uuid: str): + raise RemnawaveApiError(status_code=404, message="not found") + + async def get_user_by_username(self, username: str): + raise RemnawaveApiError(status_code=404, message="not found") + + async def get_users_by_telegram_id(self, telegram_id: int): + return list(self.users_by_telegram_id.get(telegram_id, [])) + + async def create_user(self, body: dict[str, object]): + self.created_bodies.append(body) + return self.created_user + + async def update_user(self, body: dict[str, object]): + self.updated_bodies.append(body) + raw_uuid = str(body.get("uuid") or "") + raw_expire_at = str(body.get("expireAt") or "") + if raw_expire_at.endswith("Z"): + raw_expire_at = raw_expire_at.replace("Z", "+00:00") + next_expire_at = datetime.fromisoformat(raw_expire_at) if raw_expire_at else None + + for telegram_id, users in self.users_by_telegram_id.items(): + for index, user in enumerate(users): + if str(user.uuid) != raw_uuid: + continue + updated_user = make_remote_user( + user_uuid=str(user.uuid), + user_id=user.id, + short_uuid=user.short_uuid, + username=user.username, + telegram_id=telegram_id, + expire_at=next_expire_at or user.expire_at, + traffic_limit_bytes=user.traffic_limit_bytes, + ) + users[index] = updated_user + return updated_user + + raise AssertionError("update_user should not be called in this scenario") + + +async def _seed_order( + session_factory, + *, + telegram_id: int, + status: str, + order_uuid: str | None = None, +) -> str: + resolved_order_uuid = order_uuid or str(uuid4()) + + async with session_factory() as session: + user = TelegramUser( + telegram_id=telegram_id, + username="tester", + first_name="Test", + language_code="ru", + ) + session.add(user) + await session.flush() + + session.add( + PaymentOrder( + order_uuid=resolved_order_uuid, + telegram_user_id=user.id, + telegram_id=telegram_id, + plan_code="30d", + plan_title="OREOL VPN на 30 дней", + plan_duration_days=30, + traffic_limit_bytes=0, + traffic_limit_strategy="NO_RESET", + amount_stars=250, + currency="RUB", + status=status, + invoice_payload=f"payload-{resolved_order_uuid}", + provision_username="Oreol-1001-tester", + error_message=None, + ) + ) + await session.commit() + + return resolved_order_uuid + + +async def _seed_referral_invite( + session_factory, + *, + inviter_telegram_id: int, + invited_telegram_id: int, +) -> None: + async with session_factory() as session: + session.add( + ReferralInvite( + inviter_telegram_id=inviter_telegram_id, + invited_telegram_id=invited_telegram_id, + invited_username="buyer", + invited_display_name="Buyer", + ) + ) + await session.commit() + + +def _make_settings() -> Settings: + return Settings.model_construct( + bot_admin_ids_raw="", + payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111", + payment_user_tag="BOT", + payment_external_squad_uuid="", + payment_username_prefix="Oreol", + payment_plan_traffic_limit_gb=0, + payment_plan_traffic_reset_period="NO_RESET", + referral_discount_percent=5, + referral_bonus_days=7, + ) + + +async def test_mark_order_under_review_only_transitions_once(session_factory) -> None: + service = PaymentService( + settings=_make_settings(), + session_factory=session_factory, + remnawave_client=_StubRemnawaveClient( + created_user=make_remote_user( + user_uuid="11111111-1111-1111-1111-111111111111", + user_id=1, + short_uuid="abc123", + username="oreol-user", + telegram_id=1001, + ) + ), + sync_service=_StubSyncService(), + ) + order_uuid = await _seed_order( + session_factory, + telegram_id=1001, + status=PENDING_PAYMENT_STATUS, + ) + + order = await service.mark_order_under_review(order_uuid=order_uuid, telegram_id=1001) + + assert order.status == REVIEW_PAYMENT_STATUS + + with pytest.raises(ValueError): + await service.mark_order_under_review(order_uuid=order_uuid, telegram_id=1001) + + +async def test_approve_order_requires_review_status(session_factory) -> None: + remote_user = make_remote_user( + user_uuid="22222222-2222-2222-2222-222222222222", + user_id=2, + short_uuid="def456", + username="oreol-user", + telegram_id=1001, + ) + service = PaymentService( + settings=_make_settings(), + session_factory=session_factory, + remnawave_client=_StubRemnawaveClient(created_user=remote_user), + sync_service=_StubSyncService(), + ) + order_uuid = await _seed_order( + session_factory, + telegram_id=1001, + status=PENDING_PAYMENT_STATUS, + ) + + with pytest.raises(ValueError): + await service.approve_order(order_uuid=order_uuid) + + +async def test_approve_order_fulfills_and_refreshes_cache(session_factory) -> None: + remote_user = make_remote_user( + user_uuid="33333333-3333-3333-3333-333333333333", + user_id=3, + short_uuid="ghi789", + username="oreol-user", + telegram_id=1001, + ) + sync_service = _StubSyncService() + remnawave_client = _StubRemnawaveClient(created_user=remote_user) + service = PaymentService( + settings=_make_settings(), + session_factory=session_factory, + remnawave_client=remnawave_client, + sync_service=sync_service, + ) + order_uuid = await _seed_order( + session_factory, + telegram_id=1001, + status=REVIEW_PAYMENT_STATUS, + ) + + access = await service.approve_order(order_uuid=order_uuid) + + assert access.remnawave_user_uuid == str(remote_user.uuid) + assert access.is_renewal is False + assert sync_service.refreshed == [1001] + assert remnawave_client.created_bodies + + async with session_factory() as session: + order = await session.scalar( + select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid) + ) + + assert order is not None + assert order.status == FULFILLED_PAYMENT_STATUS + assert order.remnawave_user_uuid == str(remote_user.uuid) + + +async def test_approve_order_grants_and_applies_referral_bonus(session_factory) -> None: + buyer_remote_user = make_remote_user( + user_uuid="44444444-4444-4444-4444-444444444444", + user_id=4, + short_uuid="buyer444", + username="oreol-buyer", + telegram_id=1001, + ) + inviter_remote_user = make_remote_user( + user_uuid="55555555-5555-5555-5555-555555555555", + user_id=5, + short_uuid="inviter555", + username="oreol-inviter", + telegram_id=2002, + ) + sync_service = _StubSyncService() + remnawave_client = _StubRemnawaveClient( + created_user=buyer_remote_user, + users_by_telegram_id={2002: [inviter_remote_user]}, + ) + service = PaymentService( + settings=_make_settings(), + session_factory=session_factory, + remnawave_client=remnawave_client, + sync_service=sync_service, + ) + order_uuid = await _seed_order( + session_factory, + telegram_id=1001, + status=REVIEW_PAYMENT_STATUS, + ) + await _seed_referral_invite( + session_factory, + inviter_telegram_id=2002, + invited_telegram_id=1001, + ) + + access = await service.approve_order(order_uuid=order_uuid) + + assert access.referral_bonus is not None + assert access.referral_bonus.inviter_telegram_id == 2002 + assert access.referral_bonus.bonus_days == 7 + assert access.referral_bonus.status == REFERRAL_BONUS_APPLIED_STATUS + assert sync_service.refreshed == [1001, 2002] + assert any( + body.get("uuid") == str(inviter_remote_user.uuid) + for body in remnawave_client.updated_bodies + ) + + async with session_factory() as session: + bonus = await session.scalar( + select(ReferralBonus).where(ReferralBonus.source_order_uuid == order_uuid) + ) + + assert bonus is not None + assert bonus.status == REFERRAL_BONUS_APPLIED_STATUS + + +async def test_approve_order_keeps_referral_bonus_pending_without_inviter_access( + session_factory, +) -> None: + buyer_remote_user = make_remote_user( + user_uuid="66666666-6666-6666-6666-666666666666", + user_id=6, + short_uuid="buyer666", + username="oreol-buyer-2", + telegram_id=1001, + ) + sync_service = _StubSyncService() + remnawave_client = _StubRemnawaveClient(created_user=buyer_remote_user) + service = PaymentService( + settings=_make_settings(), + session_factory=session_factory, + remnawave_client=remnawave_client, + sync_service=sync_service, + ) + order_uuid = await _seed_order( + session_factory, + telegram_id=1001, + status=REVIEW_PAYMENT_STATUS, + ) + await _seed_referral_invite( + session_factory, + inviter_telegram_id=2002, + invited_telegram_id=1001, + ) + + access = await service.approve_order(order_uuid=order_uuid) + + assert access.referral_bonus is not None + assert access.referral_bonus.status == REFERRAL_BONUS_PENDING_STATUS + assert sync_service.refreshed == [1001] + + async with session_factory() as session: + bonus = await session.scalar( + select(ReferralBonus).where(ReferralBonus.source_order_uuid == order_uuid) + ) + + assert bonus is not None + assert bonus.status == REFERRAL_BONUS_PENDING_STATUS + + +async def test_create_order_generates_unique_provision_username(session_factory) -> None: + service = PaymentService( + settings=Settings.model_construct( + bot_admin_ids_raw="", + payment_plans_raw="30:250", + payment_transfer_text="Реквизиты", + payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111", + payment_user_tag="BOT", + payment_external_squad_uuid="", + payment_username_prefix="Oreol", + payment_plan_traffic_limit_gb=0, + payment_plan_traffic_reset_period="NO_RESET", + referral_discount_percent=5, + referral_bonus_days=7, + ), + session_factory=session_factory, + remnawave_client=_StubRemnawaveClient( + created_user=make_remote_user( + user_uuid="77777777-7777-7777-7777-777777777777", + user_id=7, + short_uuid="buyer777", + username="oreol-user", + telegram_id=1001, + ) + ), + sync_service=_StubSyncService(), + ) + + first_order = await service.create_order( + telegram_id=1001, + username="tester", + first_name="Test", + last_name=None, + language_code="ru", + plan_code="30d", + ) + second_order = await service.create_order( + telegram_id=1001, + username="tester", + first_name="Test", + last_name=None, + language_code="ru", + plan_code="30d", + ) + + assert first_order.provision_username != second_order.provision_username + assert first_order.extends_existing_access is False + assert second_order.extends_existing_access is False + + +async def test_create_order_marks_existing_access_as_renewal(session_factory) -> None: + service = PaymentService( + settings=Settings.model_construct( + bot_admin_ids_raw="", + payment_plans_raw="30:250", + payment_transfer_text="Реквизиты", + payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111", + payment_user_tag="BOT", + payment_external_squad_uuid="", + payment_username_prefix="Oreol", + payment_plan_traffic_limit_gb=0, + payment_plan_traffic_reset_period="NO_RESET", + referral_discount_percent=5, + referral_bonus_days=7, + ), + session_factory=session_factory, + remnawave_client=_StubRemnawaveClient( + created_user=make_remote_user( + user_uuid="77777777-7777-7777-7777-777777777777", + user_id=7, + short_uuid="buyer777", + username="oreol-user", + telegram_id=1001, + ) + ), + sync_service=_StubSyncService(cached_users_by_telegram={1001: [object()]}), + ) + + order = await service.create_order( + telegram_id=1001, + username="tester", + first_name="Test", + last_name=None, + language_code="ru", + plan_code="30d", + ) + + assert order.extends_existing_access is True + assert "продлит текущую подписку" in service.build_transfer_text(order=order) + + +async def test_approve_order_extends_existing_user_found_by_telegram_id(session_factory) -> None: + existing_remote_user = make_remote_user( + user_uuid="88888888-8888-8888-8888-888888888888", + user_id=8, + short_uuid="existing888", + username="oreol-existing", + telegram_id=1001, + ) + sync_service = _StubSyncService() + remnawave_client = _StubRemnawaveClient( + created_user=existing_remote_user, + users_by_telegram_id={1001: [existing_remote_user]}, + ) + service = PaymentService( + settings=_make_settings(), + session_factory=session_factory, + remnawave_client=remnawave_client, + sync_service=sync_service, + ) + order_uuid = await _seed_order( + session_factory, + telegram_id=1001, + status=REVIEW_PAYMENT_STATUS, + ) + + access = await service.approve_order(order_uuid=order_uuid) + + assert access.remnawave_user_uuid == str(existing_remote_user.uuid) + assert access.is_renewal is True + assert remnawave_client.created_bodies == [] + assert remnawave_client.updated_bodies diff --git a/tests/test_remnawave_client.py b/tests/test_remnawave_client.py new file mode 100644 index 0000000..20c22b3 --- /dev/null +++ b/tests/test_remnawave_client.py @@ -0,0 +1,59 @@ +import httpx + +from app.services.remnawave_client import RemnawaveApiClient + + +def test_normalize_base_url_adds_api_suffix() -> None: + assert ( + RemnawaveApiClient.normalize_base_url("https://panel.example.com") + == "https://panel.example.com/api/" + ) + + +def test_normalize_base_url_keeps_existing_api_suffix() -> None: + assert ( + RemnawaveApiClient.normalize_base_url("https://panel.example.com/api/") + == "https://panel.example.com/api/" + ) + + +def test_unwrap_payload_response_field() -> None: + payload = {"response": {"ok": True}} + assert RemnawaveApiClient.unwrap_payload(payload) == {"ok": True} + + +def test_build_error_includes_error_code() -> None: + client = object.__new__(RemnawaveApiClient) + response = httpx.Response( + 500, + json={ + "message": "Failed to create user", + "errorCode": "A018", + }, + request=httpx.Request("POST", "https://panel.example.com/api/users"), + ) + + error = client._build_error(response) + + assert error.message == "Failed to create user [A018]" + + +def test_build_error_includes_validation_details() -> None: + client = object.__new__(RemnawaveApiClient) + response = httpx.Response( + 400, + json={ + "message": "Validation failed", + "errors": [ + { + "message": "Invalid uuid", + "path": ["uuid"], + } + ], + }, + request=httpx.Request("DELETE", "https://panel.example.com/api/users/not-a-uuid"), + ) + + error = client._build_error(response) + + assert error.message == "Validation failed | uuid: Invalid uuid" diff --git a/tests/test_sync_service.py b/tests/test_sync_service.py new file mode 100644 index 0000000..4dfa94c --- /dev/null +++ b/tests/test_sync_service.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import select + +from app.config import Settings +from app.db.models import ReferralCode, ReferralInvite, RemnawaveUser, TelegramUser +from app.services.sync_service import SyncService +from tests.helpers import make_remote_user + + +class _StubRemnawaveClient: + def __init__(self, users) -> None: + self.users = users + + async def get_users_by_telegram_id(self, telegram_id: int): + return list(self.users) + + +async def test_refresh_cached_users_clears_stale_links_and_keeps_profile( + session_factory, +) -> None: + user_one = make_remote_user( + user_uuid="44444444-4444-4444-4444-444444444444", + user_id=4, + short_uuid="user-one", + username="oreol-one", + telegram_id=1001, + ) + user_two = make_remote_user( + user_uuid="55555555-5555-5555-5555-555555555555", + user_id=5, + short_uuid="user-two", + username="oreol-two", + telegram_id=1001, + ) + client = _StubRemnawaveClient([user_one, user_two]) + service = SyncService( + settings=Settings.model_construct( + bot_admin_ids_raw="", + sync_subscription_history=False, + ), + session_factory=session_factory, + remnawave_client=client, + ) + + await service.sync_users_for_telegram( + telegram_id=1001, + username="keeper", + first_name="Keep", + last_name="Me", + language_code="ru", + ) + + client.users = [user_one] + views = await service.refresh_cached_users_for_telegram(telegram_id=1001) + + assert [view.record.rw_uuid for view in views] == [str(user_one.uuid)] + + async with session_factory() as session: + telegram_user = await session.scalar( + select(TelegramUser).where(TelegramUser.telegram_id == 1001) + ) + stale_user = await session.scalar( + select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(user_two.uuid)) + ) + + assert telegram_user is not None + assert telegram_user.username == "keeper" + assert stale_user is not None + assert stale_user.telegram_id is None + assert stale_user.owner_telegram_user_id is None + + +async def test_get_admin_telegram_users_page_returns_paginated_users( + session_factory, +) -> None: + service = SyncService( + settings=Settings.model_construct( + bot_admin_ids_raw="", + sync_subscription_history=False, + ), + session_factory=session_factory, + remnawave_client=_StubRemnawaveClient([]), + ) + + async with session_factory() as session: + session.add_all( + [ + TelegramUser( + telegram_id=1001, + username="first", + first_name="First", + last_name="User", + language_code="ru", + last_seen_at=datetime(2026, 4, 21, 10, 0), + created_at=datetime(2026, 4, 1, 10, 0), + updated_at=datetime(2026, 4, 21, 10, 0), + ), + TelegramUser( + telegram_id=1002, + username="second", + first_name="Second", + last_name="User", + language_code="ru", + last_seen_at=datetime(2026, 4, 22, 12, 0), + created_at=datetime(2026, 4, 2, 10, 0), + updated_at=datetime(2026, 4, 22, 12, 0), + ), + TelegramUser( + telegram_id=1003, + username="third", + first_name="Third", + last_name="User", + language_code="ru", + last_seen_at=datetime(2026, 4, 22, 12, 0), + created_at=datetime(2026, 4, 3, 10, 0), + updated_at=datetime(2026, 4, 22, 12, 0), + ), + ] + ) + await session.commit() + + page_one = await service.get_admin_telegram_users_page(page=1, page_size=2) + page_two = await service.get_admin_telegram_users_page(page=2, page_size=2) + + assert page_one.total_items == 3 + assert page_one.total_pages == 2 + assert [item.telegram_id for item in page_one.items] == [1003, 1002] + assert [item.telegram_id for item in page_two.items] == [1001] + + +async def test_get_admin_telegram_user_profile_returns_referral_and_access_data( + session_factory, +) -> None: + remote_user = make_remote_user( + user_uuid="66666666-6666-6666-6666-666666666666", + user_id=6, + short_uuid="profile-user", + username="oreol-profile", + telegram_id=2001, + ) + service = SyncService( + settings=Settings.model_construct( + bot_admin_ids_raw="1001", + sync_subscription_history=False, + ), + session_factory=session_factory, + remnawave_client=_StubRemnawaveClient([remote_user]), + ) + + await service.sync_users_for_telegram( + telegram_id=2001, + username="profile", + first_name="Profile", + last_name="User", + language_code="ru", + ) + + async with session_factory() as session: + session.add( + TelegramUser( + telegram_id=1001, + username="inviter", + first_name="Inviter", + last_name="Admin", + language_code="ru", + is_admin=True, + last_seen_at=datetime(2026, 4, 22, 11, 0), + created_at=datetime(2026, 4, 1, 8, 0), + updated_at=datetime(2026, 4, 22, 11, 0), + ) + ) + session.add( + ReferralCode(telegram_id=1001, code="INVITER1001") + ) + session.add( + ReferralCode(telegram_id=2001, code="SELF2001") + ) + session.add_all( + [ + ReferralInvite( + inviter_telegram_id=1001, + invited_telegram_id=2001, + invited_username="profile", + invited_display_name="Profile User", + ), + ReferralInvite( + inviter_telegram_id=2001, + invited_telegram_id=3001, + invited_username="child", + invited_display_name="Child User", + ), + ] + ) + await session.commit() + + profile = await service.get_admin_telegram_user_profile(telegram_id=2001) + + assert profile is not None + assert profile.user.telegram_id == 2001 + assert profile.referral_code == "SELF2001" + assert profile.applied_referral_code == "INVITER1001" + assert profile.invited_count == 1 + assert profile.recent_invited_names == ["Child User"] + assert len(profile.accesses) == 1 + assert profile.accesses[0].record.username == "oreol-profile"