Compare commits
8 Commits
v0.10.0-beta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 23f5e782f8 | |||
| cbb2133991 | |||
| 30866bb244 | |||
| fd22714c9b | |||
| 5e3229e998 | |||
| d0dc8d5822 | |||
| 65495ee2bf | |||
| 7d63603150 |
+23
-8
@@ -7,14 +7,29 @@ BOT_DEBUG=false
|
||||
# Telegram user ID администратора (узнать: @userinfobot или @getidsbot)
|
||||
TELEGRAM_ADMIN_ID=123456789
|
||||
|
||||
# Remnawave — панель 1 (https://docs.rw/)
|
||||
# --- Remnawave (официальные имена: https://docs.rw/docs/install/subscription-page/bundled) ---
|
||||
REMNAWAVE_PANEL_NAME=Панель 1
|
||||
# URL панели: https://panel.example.com или http://remnawave:3000 (внутри Docker-сети)
|
||||
REMNAWAVE_PANEL_URL=https://panel.example.com
|
||||
# Settings → API Tokens в панели Remnawave
|
||||
REMNAWAVE_API_TOKEN=your_api_token_here
|
||||
# Опционально, если перед панелью стоит Caddy с X-Api-Key
|
||||
REMNAWAVE_CADDY_TOKEN=
|
||||
# Публичная страница подписки (Subscription Page), для проверки доступности
|
||||
REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com
|
||||
# API-токен: Remnawave Settings → API Tokens (Authorization: Bearer)
|
||||
REMNAWAVE_API_TOKEN=API_TOKEN_FROM_REMNAWAVE
|
||||
# Если используется Caddy with security — X-Api-Key к панели
|
||||
CADDY_AUTH_API_TOKEN=
|
||||
# Опционально: Subscription Page (например https://sub.example.com)
|
||||
REMNAWAVE_SUBSCRIPTION_URL=
|
||||
|
||||
# Docker Compose читает этот файл как .env (скопируйте: cp .env.example .env)
|
||||
# PostgreSQL (должен совпадать с POSTGRES_* ниже; install.sh сгенерирует автоматически)
|
||||
POSTGRES_USER=tgvpn
|
||||
POSTGRES_PASSWORD=change_me_strong_password
|
||||
POSTGRES_DB=tgvpn
|
||||
DATABASE_URL=postgres://tgvpn:change_me_strong_password@db:5432/tgvpn?sslmode=disable
|
||||
|
||||
# Срок подписки: для /config у пользователей бота
|
||||
TRIAL_USER_DAYS=1
|
||||
# Для /admin user (создание админом)
|
||||
DEFAULT_USER_DAYS=1
|
||||
# UUID сквадов из панели (/admin squads), через запятую для internal
|
||||
DEFAULT_EXTERNAL_SQUAD_UUID=
|
||||
DEFAULT_INTERNAL_SQUAD_UUIDS=
|
||||
|
||||
# Docker Compose: cp .env.example .env
|
||||
|
||||
+36
-1
@@ -2,6 +2,41 @@
|
||||
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Добавлено
|
||||
|
||||
- `/config` и кнопка «Получить конфиг» — trial-подписка на `TRIAL_USER_DAYS` (по умолчанию 1 день), создание пользователя в Remnawave и ссылка на подписку
|
||||
- `install.sh` — интерактивный установщик на Linux-сервер (опрос параметров, `.env`, Docker)
|
||||
- PostgreSQL 16 в Docker Compose (`DATABASE_URL`)
|
||||
- Создание пользователей Remnawave: `/admin user`, `/admin user <логин> [дней]`
|
||||
- Назначение сквадов: external + internal (`/admin assign <логин>`, мастер с кнопками)
|
||||
- `/admin squads` — список сквадов из API
|
||||
- Сохранение VPN-пользователей и состояния мастера в БД
|
||||
|
||||
## [0.20.0] — 2026-05-21
|
||||
|
||||
### Изменено
|
||||
|
||||
- Конфигурация Remnawave приведена к [официальной документации](https://docs.rw/docs/install/subscription-page/bundled):
|
||||
- `REMNAWAVE_PANEL_URL` — URL панели и API (`/api/...`)
|
||||
- `REMNAWAVE_API_TOKEN` — `Authorization: Bearer`
|
||||
- `CADDY_AUTH_API_TOKEN` — `X-Api-Key` (вместо `REMNAWAVE_CADDY_TOKEN`, старое имя поддерживается)
|
||||
- Удалён `REMNAWAVE_API_URL` (отдельный URL API в Remnawave не используется)
|
||||
|
||||
### Исправлено
|
||||
|
||||
- `/admin check`: отчёт без Markdown — URL и имена переменных отображаются корректно
|
||||
- Страница подписки (`REMNAWAVE_SUBSCRIPTION_URL`) — опциональная проверка, не ошибка если не задана
|
||||
- Подсказка при HTTP 502: различие домена панели (`panel.*`) и подписки (`sub.*`)
|
||||
|
||||
### Добавлено
|
||||
|
||||
- Раздел в README: Remnawave API (по официальной документации)
|
||||
- Пример `curl` для проверки API с сервера
|
||||
|
||||
[0.20.0]: #
|
||||
|
||||
## [0.10.0-beta] — 2026-05-21
|
||||
|
||||
Первый публичный beta-релиз Telegram-бота для VPN на базе [Remnawave](https://docs.rw/).
|
||||
@@ -32,4 +67,4 @@
|
||||
- `internal/config` — загрузка конфигурации
|
||||
- `internal/remnawave` — HTTP-клиент и health-check панели
|
||||
|
||||
[0.10.0-beta]: https://git.evilfox.cc/test/tgvpn/releases/tag/v0.10.0-beta
|
||||
[0.10.0-beta]: #
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
# tgvpn
|
||||
|
||||
**Версия:** [0.10.0-beta](CHANGELOG.md) · [Релизы](https://git.evilfox.cc/test/tgvpn/releases)
|
||||
**Версия:** [0.20.0](CHANGELOG.md)
|
||||
|
||||
Telegram-бот на Go (базовое приветствие; далее — VPN-функции).
|
||||
Telegram-бот на Go для управления VPN через панель [Remnawave](https://docs.rw/): проверка панели, создание пользователей, назначение сквадов. Данные хранятся в **PostgreSQL**.
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Требования](#требования)
|
||||
- [Установщик (рекомендуется)](#установщик-на-сервере)
|
||||
- [Быстрый старт](#быстрый-старт-docker-compose)
|
||||
- [PostgreSQL](#postgresql)
|
||||
- [Развёртывание на VPS](#развёртывание-на-vps-linux)
|
||||
- [Обновление бота](#обновление-бота)
|
||||
- [Переменные окружения](#переменные-окружения)
|
||||
- [Админ-меню](#админ-меню-в-боте)
|
||||
- [Устранение неполадок](#устранение-неполадок)
|
||||
|
||||
## Требования
|
||||
|
||||
@@ -10,6 +22,7 @@ Telegram-бот на Go (базовое приветствие; далее — V
|
||||
|-----------|---------|
|
||||
| Docker | 24+ |
|
||||
| Docker Compose | v2 (`docker compose`) |
|
||||
| PostgreSQL | 16+ (в compose включён) |
|
||||
| Токен бота | [@BotFather](https://t.me/BotFather) |
|
||||
| Сеть | Исходящий HTTPS к `api.telegram.org` (порт 443) |
|
||||
|
||||
@@ -17,12 +30,69 @@ Telegram-бот на Go (базовое приветствие; далее — V
|
||||
|
||||
---
|
||||
|
||||
## Установщик на сервере
|
||||
|
||||
Интерактивный скрипт запросит все параметры, создаст `.env` и запустит Docker.
|
||||
|
||||
### Требования на сервере
|
||||
|
||||
- Linux (Ubuntu 22.04/24.04, Debian 12)
|
||||
- `curl`, `git` (для клонирования)
|
||||
- Права `sudo` (для установки Docker при необходимости)
|
||||
|
||||
### Установка одной командой
|
||||
|
||||
Если репозиторий уже на сервере:
|
||||
|
||||
```bash
|
||||
cd tgvpn
|
||||
chmod +x install.sh
|
||||
./install.sh
|
||||
```
|
||||
|
||||
Или скачайте скрипт и укажите каталог `/opt/tgvpn`:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/tgvpn
|
||||
cd /opt/tgvpn
|
||||
git clone <URL-вашего-репозитория> .
|
||||
chmod +x install.sh
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### Что спрашивает установщик
|
||||
|
||||
| Блок | Параметры |
|
||||
|------|-----------|
|
||||
| Telegram | `BOT_TOKEN`, `TELEGRAM_ADMIN_ID`, `BOT_DEBUG` |
|
||||
| Remnawave | URL панели, API token, Caddy token, subscription URL |
|
||||
| PostgreSQL | пользователь, база, пароль (можно сгенерировать случайный) |
|
||||
| VPN | срок по умолчанию, UUID сквадов (опционально) |
|
||||
| Система | каталог установки, URL git (если не из текущей папки) |
|
||||
|
||||
После завершения: `docker compose up -d --build`, проверка `docker compose ps`.
|
||||
|
||||
### Переменные окружения для PostgreSQL в compose
|
||||
|
||||
В `.env` должны совпадать `POSTGRES_PASSWORD` и пароль в `DATABASE_URL`:
|
||||
|
||||
```env
|
||||
POSTGRES_USER=tgvpn
|
||||
POSTGRES_PASSWORD=ваш_сильный_пароль
|
||||
POSTGRES_DB=tgvpn
|
||||
DATABASE_URL=postgres://tgvpn:ваш_сильный_пароль@db:5432/tgvpn?sslmode=disable
|
||||
```
|
||||
|
||||
Установщик заполняет это автоматически.
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт (Docker Compose)
|
||||
|
||||
### 1. Клонирование
|
||||
|
||||
```bash
|
||||
git clone https://git.evilfox.cc/test/tgvpn.git
|
||||
git clone <URL-вашего-репозитория>
|
||||
cd tgvpn
|
||||
```
|
||||
|
||||
@@ -42,12 +112,16 @@ REMNAWAVE_PANEL_NAME=Панель 1
|
||||
REMNAWAVE_PANEL_URL=https://panel.example.com
|
||||
REMNAWAVE_API_TOKEN=токен_из_панели
|
||||
REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com
|
||||
DATABASE_URL=postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable
|
||||
DEFAULT_USER_DAYS=30
|
||||
```
|
||||
|
||||
> **Важно:** файл `.env` не попадает в git и не копируется в образ. Compose передаёт переменные в контейнер при старте.
|
||||
|
||||
### 3. Сборка и запуск
|
||||
|
||||
Поднимаются два сервиса: **PostgreSQL** (`db`) и **бот** (`bot`). Бот стартует только после готовности БД.
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
@@ -55,23 +129,164 @@ docker compose up -d --build
|
||||
### 4. Проверка
|
||||
|
||||
```bash
|
||||
# логи (должно быть: «бот @имя_бота запущен»)
|
||||
docker compose logs -f bot
|
||||
|
||||
# статус контейнера
|
||||
# статус (db — healthy, bot — running)
|
||||
docker compose ps
|
||||
|
||||
# логи бота (должно быть: «postgres ok», «бот @имя запущен»)
|
||||
docker compose logs --tail=30 bot
|
||||
|
||||
# логи PostgreSQL
|
||||
docker compose logs --tail=20 db
|
||||
```
|
||||
|
||||
В Telegram откройте бота и отправьте `/start`.
|
||||
В Telegram: `/start` → кнопка «Получить конфиг» или `/config` (trial на `TRIAL_USER_DAYS`, по умолчанию 1 день). От админа — `/admin squads`, `/admin user`.
|
||||
|
||||
### 5. Остановка
|
||||
|
||||
```bash
|
||||
# остановить контейнеры (данные БД сохраняются в volume pgdata)
|
||||
docker compose down
|
||||
|
||||
# удалить и данные БД (осторожно!)
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
Бот **не работает без PostgreSQL**: при старте проверяется `DATABASE_URL`, применяется миграция, далее идёт работа с БД.
|
||||
|
||||
### Роль базы данных
|
||||
|
||||
| Таблица | Назначение |
|
||||
|---------|------------|
|
||||
| `telegram_users` | Пользователи Telegram, зашедшие в бота (`/start`) |
|
||||
| `vpn_users` | Созданные в Remnawave аккаунты: UUID, логин, сквады, срок |
|
||||
| `admin_wizard` | Состояние мастера админа (создание пользователя, назначение сквадов) |
|
||||
|
||||
Миграции лежат в `internal/db/migrations/` и применяются **автоматически** при каждом запуске бота (`CREATE TABLE IF NOT EXISTS`).
|
||||
|
||||
### Схема в Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
db: # PostgreSQL 16, volume pgdata
|
||||
bot: # ждёт healthy у db, затем стартует
|
||||
```
|
||||
|
||||
Параметры по умолчанию (см. `docker-compose.yml`):
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Хост (внутри compose) | `db` |
|
||||
| Порт | `5432` |
|
||||
| База | `tgvpn` |
|
||||
| Пользователь | `tgvpn` |
|
||||
| Пароль | `tgvpn` |
|
||||
|
||||
Строка подключения для бота:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable
|
||||
```
|
||||
|
||||
Формат URL (общий вид):
|
||||
|
||||
```
|
||||
postgres://USER:PASSWORD@HOST:PORT/DATABASE?sslmode=disable
|
||||
```
|
||||
|
||||
### Подключение к БД вручную
|
||||
|
||||
Из каталога проекта:
|
||||
|
||||
```bash
|
||||
# интерактивная консоль psql внутри контейнера
|
||||
docker compose exec db psql -U tgvpn -d tgvpn
|
||||
```
|
||||
|
||||
Полезные запросы:
|
||||
|
||||
```sql
|
||||
-- все VPN-пользователи, созданные через бота
|
||||
SELECT remnawave_username, remnawave_uuid, expire_at, created_at FROM vpn_users;
|
||||
|
||||
-- активные мастера админа
|
||||
SELECT admin_telegram_id, step, updated_at FROM admin_wizard;
|
||||
|
||||
\q
|
||||
```
|
||||
|
||||
### Продакшен: смена пароля БД
|
||||
|
||||
1. Задайте сильный пароль в `docker-compose.yml` (секция `db.environment`) **или** вынесите в `.env`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me_strong}
|
||||
```
|
||||
|
||||
2. Обновите `DATABASE_URL` в `.env` бота с тем же паролем.
|
||||
|
||||
3. Пересоздайте стек:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
> Если меняете пароль у уже существующего volume `pgdata`, может понадобиться сброс volume или `ALTER USER` внутри старой БД.
|
||||
|
||||
### Внешний PostgreSQL (без контейнера `db`)
|
||||
|
||||
Если БД на отдельном сервере или managed Postgres:
|
||||
|
||||
1. Создайте базу и пользователя с правами `CREATE`, `SELECT`, `INSERT`, `UPDATE`, `DELETE`.
|
||||
2. В `.env` укажите реальный URL:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgres://user:password@postgres.example.com:5432/tgvpn?sslmode=require
|
||||
```
|
||||
|
||||
3. В `docker-compose.yml` закомментируйте или удалите сервис `db` и `depends_on` у `bot`.
|
||||
4. Убедитесь, что с хоста бота есть сетевой доступ к порту `5432`.
|
||||
|
||||
### Резервное копирование
|
||||
|
||||
```bash
|
||||
# дамп в файл (дата в имени)
|
||||
docker compose exec -T db pg_dump -U tgvpn tgvpn > backup_$(date +%Y%m%d).sql
|
||||
|
||||
# восстановление (на пустую или новую БД)
|
||||
cat backup_20260101.sql | docker compose exec -T db psql -U tgvpn -d tgvpn
|
||||
```
|
||||
|
||||
Рекомендуется настроить cron на VPS для ежедневных дампов.
|
||||
|
||||
### Проверка после деплоя
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
# tgvpn-db running (healthy)
|
||||
# tgvpn-bot running
|
||||
|
||||
docker compose logs bot | grep -i postgres
|
||||
# ожидается успешный старт без «ping postgres» / «apply migration» ошибок
|
||||
```
|
||||
|
||||
### Ошибки PostgreSQL
|
||||
|
||||
| Симптом | Решение |
|
||||
|---------|---------|
|
||||
| `DATABASE_URL не задан` | Добавьте переменную в `.env` |
|
||||
| `connect postgres` / `ping postgres` | Проверьте, что `db` в состоянии `healthy`: `docker compose ps` |
|
||||
| Бот стартует раньше БД | В compose уже есть `depends_on: condition: service_healthy` — обновите compose |
|
||||
| `password authentication failed` | Совпадение пароля в `POSTGRES_PASSWORD` и в `DATABASE_URL` |
|
||||
| Пустые таблицы после создания user | Смотрите логи бота и ответ Remnawave API; запись в `vpn_users` идёт после успешного `POST /api/users` |
|
||||
|
||||
---
|
||||
|
||||
## Развёртывание на VPS (Linux)
|
||||
|
||||
Ниже — пошаговая установка на чистый сервер (Ubuntu 22.04/24.04, Debian 12). Аналогично на других дистрибутивах с Docker.
|
||||
@@ -115,7 +330,7 @@ docker compose version
|
||||
sudo mkdir -p /opt/tgvpn
|
||||
sudo chown $USER:$USER /opt/tgvpn
|
||||
cd /opt/tgvpn
|
||||
git clone https://git.evilfox.cc/test/tgvpn.git .
|
||||
git clone <URL-вашего-репозитория> .
|
||||
```
|
||||
|
||||
### Шаг 4. Настройка `.env`
|
||||
@@ -125,7 +340,9 @@ cp .env.example .env
|
||||
nano .env # или vim / vi
|
||||
```
|
||||
|
||||
Укажите реальный `BOT_TOKEN`. Для продакшена оставьте `BOT_DEBUG=false`.
|
||||
Укажите реальный `BOT_TOKEN`, `DATABASE_URL` (в compose для VPS обычно оставляют `postgres://tgvpn:...@db:5432/...`), Remnawave и при необходимости UUID сквадов (`DEFAULT_*`).
|
||||
|
||||
Для продакшена смените пароль PostgreSQL — см. [PostgreSQL → Продакшен](#продакшен-смена-пароля-бд).
|
||||
|
||||
Права на секреты:
|
||||
|
||||
@@ -144,6 +361,7 @@ docker compose up -d --build
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs --tail=50 bot
|
||||
docker compose logs --tail=20 db
|
||||
```
|
||||
|
||||
### Шаг 6. Автозапуск после перезагрузки сервера
|
||||
@@ -260,7 +478,7 @@ docker image prune -f
|
||||
| `git pull` конфликтует с локальными правками | `git stash` → `git pull` → `git stash pop` или сбросить локальные изменения: `git checkout -- .` |
|
||||
| Бот не стартует после pull | `docker compose logs bot` — часто не хватает новой переменной в `.env` |
|
||||
| Старый код в контейнере | Обязательно `--build`: `docker compose up -d --build` |
|
||||
| Нет доступа к git | Проверьте SSH/HTTPS-доступ к `git.evilfox.cc` |
|
||||
| Нет доступа к git | Проверьте SSH/HTTPS-доступ к вашему git-серверу |
|
||||
|
||||
---
|
||||
|
||||
@@ -272,7 +490,7 @@ docker image prune -f
|
||||
2. В PowerShell:
|
||||
|
||||
```powershell
|
||||
git clone https://git.evilfox.cc/test/tgvpn.git
|
||||
git clone <URL-вашего-репозитория>
|
||||
cd tgvpn
|
||||
Copy-Item .env.example .env
|
||||
# отредактируйте .env — вставьте BOT_TOKEN
|
||||
@@ -284,9 +502,31 @@ docker compose logs -f bot
|
||||
|
||||
## Локальная разработка (без Docker)
|
||||
|
||||
Нужен запущенный PostgreSQL 16+ (локально или только контейнер БД):
|
||||
|
||||
```bash
|
||||
# вариант: только БД в Docker, бот на хосте
|
||||
docker compose up -d db
|
||||
```
|
||||
|
||||
В `.env` для локального бота укажите:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgres://tgvpn:tgvpn@localhost:5432/tgvpn?sslmode=disable
|
||||
```
|
||||
|
||||
Проброс порта в `docker-compose.yml` (если ещё нет):
|
||||
|
||||
```yaml
|
||||
db:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
```
|
||||
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# укажите BOT_TOKEN в .env
|
||||
go run .
|
||||
```
|
||||
|
||||
@@ -306,12 +546,24 @@ go build -o bot .
|
||||
| `BOT_TOKEN` | да | Токен от @BotFather |
|
||||
| `TELEGRAM_ADMIN_ID` | да | Числовой Telegram user ID администратора (например, [@userinfobot](https://t.me/userinfobot)) |
|
||||
| `REMNAWAVE_PANEL_NAME` | нет | Название панели в админ-меню (по умолчанию «Панель 1») |
|
||||
| `REMNAWAVE_PANEL_URL` | да | URL панели Remnawave, например `https://vpn.example.com` |
|
||||
| `REMNAWAVE_API_TOKEN` | да | API-токен: панель → **Settings → API Tokens** ([документация](https://docs.rw/)) |
|
||||
| `REMNAWAVE_CADDY_TOKEN` | нет | Доп. заголовок `X-Api-Key`, если панель за Caddy |
|
||||
| `REMNAWAVE_SUBSCRIPTION_URL` | нет* | URL страницы подписки для проверки в `/admin check` (*рекомендуется) |
|
||||
| `REMNAWAVE_PANEL_URL` | да | URL панели — сюда же идут запросы API (`/api/...`). Пример: `https://panel.example.com` ([док](https://docs.rw/docs/install/subscription-page/bundled)) |
|
||||
| `REMNAWAVE_API_TOKEN` | да | Токен из **Remnawave Settings → API Tokens**, заголовок `Authorization: Bearer` |
|
||||
| `CADDY_AUTH_API_TOKEN` | нет | `X-Api-Key`, если включён Caddy with security (как в оф. `.env` subscription-page) |
|
||||
| `REMNAWAVE_SUBSCRIPTION_URL` | нет | Опционально: домен Subscription Page (`sub.*`), отдельная проверка |
|
||||
| `DATABASE_URL` | да | PostgreSQL, в compose: `postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable` |
|
||||
| `TRIAL_USER_DAYS` | нет | Срок trial-конфига для `/config` (по умолчанию 1) |
|
||||
| `DEFAULT_USER_DAYS` | нет | Срок при создании админом `/admin user` (по умолчанию 1) |
|
||||
| `DEFAULT_EXTERNAL_SQUAD_UUID` | нет | External squad по умолчанию при быстром создании |
|
||||
| `DEFAULT_INTERNAL_SQUAD_UUIDS` | нет | Internal squads через запятую |
|
||||
| `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) |
|
||||
|
||||
### Команды для пользователей
|
||||
|
||||
- `/start` — приветствие и кнопка получения конфига
|
||||
- `/config` — создать пользователя в Remnawave на `TRIAL_USER_DAYS` (если активная подписка уже есть — вернёт существующую ссылку)
|
||||
|
||||
Нужны `DEFAULT_EXTERNAL_SQUAD_UUID` и `DEFAULT_INTERNAL_SQUAD_UUIDS` — те же сквады, что для быстрого `/admin user`.
|
||||
|
||||
### Админ-меню в боте
|
||||
|
||||
Только пользователь с `TELEGRAM_ADMIN_ID`:
|
||||
@@ -319,7 +571,36 @@ go build -o bot .
|
||||
- `/admin` — админ-меню (панель 1, Remnawave)
|
||||
- `/admin check` — полная проверка: веб панели, API (статистика, users, nodes), подписка (settings + API), страница подписки
|
||||
- `/admin config` — конфиг панели в боте
|
||||
- Кнопки снизу (после `/start`): «Проверить панель», «Конфиг панели»
|
||||
- `/admin user` — мастер создания пользователя в Remnawave + назначение сквадов
|
||||
- `/admin user <логин> [дней]` — быстрое создание (сквады из `DEFAULT_*` в `.env`)
|
||||
- `/admin squads` — список internal/external squads
|
||||
- `/admin assign <логин>` — назначить сквады существующему пользователю
|
||||
- Кнопки: «Создать пользователя», «Сквады», «Проверить панель», «Конфиг»
|
||||
|
||||
---
|
||||
|
||||
## Remnawave API (по официальной документации)
|
||||
|
||||
Как в [Bundled Subscription Page](https://docs.rw/docs/install/subscription-page/bundled):
|
||||
|
||||
```env
|
||||
REMNAWAVE_PANEL_URL=https://panel.example.com
|
||||
REMNAWAVE_API_TOKEN=API_TOKEN_FROM_REMNAWAVE
|
||||
CADDY_AUTH_API_TOKEN=
|
||||
```
|
||||
|
||||
- **Отдельного `REMNAWAVE_API_URL` нет** — API всегда на том же хосте, что и панель: `{REMNAWAVE_PANEL_URL}/api/...`
|
||||
- Авторизация: `Authorization: Bearer {REMNAWAVE_API_TOKEN}`
|
||||
- Внутри Docker-сети Remnawave: `REMNAWAVE_PANEL_URL=http://remnawave:3000`
|
||||
- Домен `sub.*` — это Subscription Page, не панель; для API используйте `panel.*`
|
||||
|
||||
Пример проверки с сервера:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: Bearer $REMNAWAVE_API_TOKEN" \
|
||||
"$REMNAWAVE_PANEL_URL/api/system/stats/recap"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -335,10 +616,16 @@ docker compose logs -f bot
|
||||
# последние 100 строк логов
|
||||
docker compose logs --tail=100 bot
|
||||
|
||||
# зайти в контейнер (обычно не нужно)
|
||||
# зайти в контейнер бота
|
||||
docker compose exec bot sh
|
||||
|
||||
# удалить контейнер (образ останется)
|
||||
# консоль PostgreSQL
|
||||
docker compose exec db psql -U tgvpn -d tgvpn
|
||||
|
||||
# дамп базы
|
||||
docker compose exec -T db pg_dump -U tgvpn tgvpn > backup.sql
|
||||
|
||||
# удалить контейнеры (данные БД в volume сохранятся)
|
||||
docker compose down
|
||||
|
||||
# удалить контейнер и неиспользуемые образы проекта
|
||||
@@ -351,8 +638,9 @@ docker compose down --rmi local
|
||||
|
||||
- Бот использует **long polling**: входящие запросы на ваш сервер **не нужны**, порты открывать не требуется.
|
||||
- Нужен только **исходящий** доступ к `https://api.telegram.org`.
|
||||
- Не коммитьте `.env` в git. Не публикуйте `BOT_TOKEN`.
|
||||
- Контейнер запускается от непривилегированного пользователя `bot` (UID 10001).
|
||||
- Не коммитьте `.env` в git. Не публикуйте `BOT_TOKEN` и пароль БД.
|
||||
- PostgreSQL доступен **только внутри docker-сети** (порт наружу не проброшен по умолчанию).
|
||||
- Контейнер бота запускается от непривилегированного пользователя `bot` (UID 10001).
|
||||
|
||||
Если позже добавите **webhook**, понадобится reverse proxy (nginx/Caddy), TLS и открытый порт 443 — это описывается отдельно при появлении функции.
|
||||
|
||||
@@ -381,6 +669,15 @@ docker compose logs bot # ошибки сети, токена
|
||||
- Убедитесь, что на сервере нет блокировки Telegram (firewall, провайдер).
|
||||
- Проверьте: `curl -I https://api.telegram.org` с хоста.
|
||||
|
||||
### API возвращает 502, веб-панель — 200
|
||||
|
||||
Частая причина: в `REMNAWAVE_PANEL_URL` указан домен **страницы подписки** (`sub.example.com`), а не **админ-панели** (`panel.example.com`).
|
||||
|
||||
1. Укажите URL **панели** (не sub): `REMNAWAVE_PANEL_URL=https://panel.example.com`
|
||||
2. Токен API: `REMNAWAVE_API_TOKEN=...` (Settings → API Tokens)
|
||||
3. Страницу подписки — опционально: `REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com`
|
||||
4. Проверьте на сервере: `docker compose ps` (Remnawave Panel запущен), логи reverse proxy
|
||||
|
||||
### Контейнер постоянно перезапускается
|
||||
|
||||
```bash
|
||||
@@ -389,6 +686,10 @@ docker compose logs --tail=200 bot
|
||||
|
||||
Чаще всего — пустой `BOT_TOKEN` или ошибка при старте.
|
||||
|
||||
### `DATABASE_URL не задан` / ошибки PostgreSQL
|
||||
|
||||
См. раздел [PostgreSQL → Ошибки](#ошибки-postgresql).
|
||||
|
||||
### Нет доступа к `docker` без sudo
|
||||
|
||||
```bash
|
||||
@@ -404,11 +705,14 @@ sudo usermod -aG docker $USER
|
||||
tgvpn/
|
||||
├── main.go
|
||||
├── internal/
|
||||
│ ├── bot/ # обработчики Telegram, админ-меню
|
||||
│ ├── bot/ # Telegram, админ-меню, создание пользователей
|
||||
│ ├── config/ # переменные окружения
|
||||
│ └── remnawave/ # клиент API панели
|
||||
│ ├── db/ # PostgreSQL: подключение, миграции, репозитории
|
||||
│ │ └── migrations/ # SQL-миграции (001_init.sql)
|
||||
│ └── remnawave/ # API панели (users, squads)
|
||||
├── Dockerfile # multi-stage сборка
|
||||
├── docker-compose.yml # оркестрация
|
||||
├── install.sh # интерактивный установщик на сервер
|
||||
├── docker-compose.yml # bot + PostgreSQL (volume pgdata)
|
||||
├── .env.example # шаблон переменных
|
||||
├── .dockerignore
|
||||
├── go.mod / go.sum
|
||||
@@ -420,4 +724,4 @@ tgvpn/
|
||||
|
||||
## Репозиторий
|
||||
|
||||
https://git.evilfox.cc/test/tgvpn.git
|
||||
Укажите URL вашего приватного git-репозитория при клонировании.
|
||||
|
||||
+25
-2
@@ -1,4 +1,22 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: tgvpn-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-tgvpn}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tgvpn}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-tgvpn}
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U tgvpn -d tgvpn"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
bot:
|
||||
build:
|
||||
context: .
|
||||
@@ -6,9 +24,14 @@ services:
|
||||
image: tgvpn-bot:latest
|
||||
container_name: tgvpn-bot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
BOT_DEBUG: ${BOT_DEBUG:-false}
|
||||
# Long polling — исходящие HTTPS к api.telegram.org
|
||||
# ports не нужны, пока нет webhook
|
||||
DATABASE_URL: ${DATABASE_URL:-postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable}
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
module telegramvpn
|
||||
|
||||
go 1.22
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/joho/godotenv v1.5.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env bash
|
||||
# Интерактивная установка tgvpn на Linux-сервер (Docker + PostgreSQL)
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/tgvpn}"
|
||||
REPO_URL=""
|
||||
USE_CURRENT_DIR=false
|
||||
SKIP_DOCKER_INSTALL=false
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${CYAN}[*]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
fail() { echo -e "${RED}[✗]${NC} $*" >&2; exit 1; }
|
||||
|
||||
prompt() {
|
||||
local label="$1"
|
||||
local default="${2:-}"
|
||||
local val
|
||||
if [[ -n "$default" ]]; then
|
||||
read -r -p "$label [$default]: " val
|
||||
echo "${val:-$default}"
|
||||
else
|
||||
read -r -p "$label: " val
|
||||
echo "$val"
|
||||
fi
|
||||
}
|
||||
|
||||
prompt_required() {
|
||||
local label="$1"
|
||||
local val=""
|
||||
while [[ -z "$val" ]]; do
|
||||
read -r -p "$label: " val
|
||||
[[ -z "$val" ]] && warn "Поле обязательно."
|
||||
done
|
||||
echo "$val"
|
||||
}
|
||||
|
||||
prompt_secret() {
|
||||
local label="$1"
|
||||
local val=""
|
||||
while [[ -z "$val" ]]; do
|
||||
read -r -s -p "$label: " val
|
||||
echo ""
|
||||
[[ -z "$val" ]] && warn "Поле обязательно."
|
||||
done
|
||||
echo "$val"
|
||||
}
|
||||
|
||||
prompt_yn() {
|
||||
local label="$1"
|
||||
local default="${2:-y}"
|
||||
local hint="Y/n"
|
||||
[[ "$default" == "n" ]] && hint="y/N"
|
||||
local ans
|
||||
read -r -p "$label [$hint]: " ans
|
||||
ans="${ans:-$default}"
|
||||
[[ "$ans" =~ ^[Yy] ]]
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
check_docker() {
|
||||
need_cmd docker && docker compose version >/dev/null 2>&1
|
||||
}
|
||||
|
||||
install_docker() {
|
||||
info "Установка Docker (официальный репозиторий)..."
|
||||
if need_cmd apt-get; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y ca-certificates curl
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
|
||||
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "${VERSION_CODENAME}") stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
fi
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
sudo usermod -aG docker "$USER" 2>/dev/null || true
|
||||
ok "Docker установлен. Если команда docker требует sudo — перелогиньтесь или выполните: newgrp docker"
|
||||
else
|
||||
fail "Автоустановка Docker поддерживается только для Debian/Ubuntu (apt). Установите Docker вручную: https://docs.docker.com/engine/install/"
|
||||
fi
|
||||
}
|
||||
|
||||
write_env() {
|
||||
local env_file="$1"
|
||||
cat > "$env_file" <<EOF
|
||||
# Сгенерировано install.sh $(date -Iseconds)
|
||||
BOT_TOKEN=${BOT_TOKEN}
|
||||
BOT_DEBUG=${BOT_DEBUG}
|
||||
TELEGRAM_ADMIN_ID=${TELEGRAM_ADMIN_ID}
|
||||
|
||||
REMNAWAVE_PANEL_NAME=${REMNAWAVE_PANEL_NAME}
|
||||
REMNAWAVE_PANEL_URL=${REMNAWAVE_PANEL_URL}
|
||||
REMNAWAVE_API_TOKEN=${REMNAWAVE_API_TOKEN}
|
||||
CADDY_AUTH_API_TOKEN=${CADDY_AUTH_API_TOKEN}
|
||||
REMNAWAVE_SUBSCRIPTION_URL=${REMNAWAVE_SUBSCRIPTION_URL}
|
||||
|
||||
POSTGRES_USER=${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB=${POSTGRES_DB}
|
||||
DATABASE_URL=${DATABASE_URL}
|
||||
|
||||
TRIAL_USER_DAYS=${TRIAL_USER_DAYS}
|
||||
DEFAULT_USER_DAYS=${DEFAULT_USER_DAYS}
|
||||
DEFAULT_EXTERNAL_SQUAD_UUID=${DEFAULT_EXTERNAL_SQUAD_UUID}
|
||||
DEFAULT_INTERNAL_SQUAD_UUIDS=${DEFAULT_INTERNAL_SQUAD_UUIDS}
|
||||
EOF
|
||||
chmod 600 "$env_file"
|
||||
}
|
||||
|
||||
banner() {
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " tgvpn — установщик Telegram-бота"
|
||||
echo " Remnawave + PostgreSQL + Docker"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
}
|
||||
|
||||
validate_db_password() {
|
||||
if [[ "$POSTGRES_PASSWORD" =~ [:@/?#\[\] ] ]]; then
|
||||
warn "В пароле PostgreSQL есть спецсимволы. Рекомендуется сгенерировать пароль (буквы/цифры)."
|
||||
if ! prompt_yn "Продолжить с этим паролем?" "n"; then
|
||||
fail "Укажите другой пароль или сгенерируйте автоматически."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
banner
|
||||
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
if [[ -f "$script_dir/docker-compose.yml" ]] && [[ -f "$script_dir/main.go" ]]; then
|
||||
if prompt_yn "Запустить установку из текущей папки ($script_dir)?" "y"; then
|
||||
USE_CURRENT_DIR=true
|
||||
INSTALL_DIR="$script_dir"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! $USE_CURRENT_DIR; then
|
||||
INSTALL_DIR="$(prompt "Каталог установки" "$INSTALL_DIR")"
|
||||
REPO_URL="$(prompt "URL git-репозитория (пусто — только каталог, код уже должен быть там)" "")"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "=== Telegram ==="
|
||||
BOT_TOKEN="$(prompt_secret "BOT_TOKEN (от @BotFather)")"
|
||||
TELEGRAM_ADMIN_ID="$(prompt_required "TELEGRAM_ADMIN_ID (числовой ID от @userinfobot)")"
|
||||
if [[ ! "$TELEGRAM_ADMIN_ID" =~ ^[0-9]+$ ]]; then
|
||||
fail "TELEGRAM_ADMIN_ID должен быть числом"
|
||||
fi
|
||||
if prompt_yn "Включить BOT_DEBUG (подробные логи)?" "n"; then
|
||||
BOT_DEBUG=true
|
||||
else
|
||||
BOT_DEBUG=false
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "=== Remnawave ==="
|
||||
REMNAWAVE_PANEL_NAME="$(prompt "Название панели в боте" "Панель 1")"
|
||||
REMNAWAVE_PANEL_URL="$(prompt_required "REMNAWAVE_PANEL_URL (https://panel.example.com)")"
|
||||
if [[ ! "$REMNAWAVE_PANEL_URL" =~ ^https?:// ]]; then
|
||||
fail "URL панели должен начинаться с http:// или https://"
|
||||
fi
|
||||
REMNAWAVE_PANEL_URL="${REMNAWAVE_PANEL_URL%/}"
|
||||
REMNAWAVE_API_TOKEN="$(prompt_secret "REMNAWAVE_API_TOKEN (Settings → API Tokens)")"
|
||||
CADDY_AUTH_API_TOKEN="$(prompt "CADDY_AUTH_API_TOKEN (опционально, Enter — пропустить)" "")"
|
||||
REMNAWAVE_SUBSCRIPTION_URL="$(prompt "REMNAWAVE_SUBSCRIPTION_URL (опционально)" "")"
|
||||
if [[ -n "$REMNAWAVE_SUBSCRIPTION_URL" ]]; then
|
||||
REMNAWAVE_SUBSCRIPTION_URL="${REMNAWAVE_SUBSCRIPTION_URL%/}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "=== PostgreSQL ==="
|
||||
POSTGRES_USER="$(prompt "Пользователь БД" "tgvpn")"
|
||||
POSTGRES_DB="$(prompt "Имя базы" "tgvpn")"
|
||||
if prompt_yn "Сгенерировать случайный пароль PostgreSQL?" "y"; then
|
||||
POSTGRES_PASSWORD="$(openssl rand -hex 16 2>/dev/null || head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 24)"
|
||||
ok "Пароль сгенерирован (сохранён в .env)"
|
||||
else
|
||||
POSTGRES_PASSWORD="$(prompt_secret "POSTGRES_PASSWORD")"
|
||||
fi
|
||||
validate_db_password
|
||||
DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable"
|
||||
|
||||
echo ""
|
||||
info "=== Пользователи VPN (по умолчанию) ==="
|
||||
TRIAL_USER_DAYS="$(prompt "Срок trial-конфига для пользователей бота (/config), дней" "1")"
|
||||
DEFAULT_USER_DAYS="$(prompt "Срок при создании админом (/admin user), дней" "1")"
|
||||
DEFAULT_EXTERNAL_SQUAD_UUID="$(prompt "DEFAULT_EXTERNAL_SQUAD_UUID (опционально)" "")"
|
||||
DEFAULT_INTERNAL_SQUAD_UUIDS="$(prompt "DEFAULT_INTERNAL_SQUAD_UUIDS через запятую (опционально)" "")"
|
||||
|
||||
echo ""
|
||||
if ! check_docker; then
|
||||
warn "Docker или Docker Compose не найдены."
|
||||
if prompt_yn "Установить Docker автоматически (Ubuntu/Debian)?" "y"; then
|
||||
install_docker
|
||||
if ! check_docker; then
|
||||
warn "После установки может потребоваться перелогин. Запустите скрипт снова или: sudo docker compose ..."
|
||||
SKIP_DOCKER_INSTALL=true
|
||||
fi
|
||||
else
|
||||
fail "Нужны Docker и плагин compose. Установите вручную и запустите скрипт снова."
|
||||
fi
|
||||
else
|
||||
ok "Docker и Docker Compose найдены"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "=== Установка файлов ==="
|
||||
|
||||
if ! $USE_CURRENT_DIR; then
|
||||
sudo mkdir -p "$INSTALL_DIR"
|
||||
sudo chown "$USER:$USER" "$INSTALL_DIR"
|
||||
if [[ -n "$REPO_URL" ]]; then
|
||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||
info "Обновление репозитория..."
|
||||
git -C "$INSTALL_DIR" pull --ff-only || warn "git pull не выполнен"
|
||||
else
|
||||
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||
fi
|
||||
elif [[ ! -f "$INSTALL_DIR/docker-compose.yml" ]]; then
|
||||
fail "В $INSTALL_DIR нет docker-compose.yml. Укажите URL репозитория или запустите скрипт из папки проекта."
|
||||
fi
|
||||
fi
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
if [[ -f .env ]]; then
|
||||
if ! prompt_yn ".env уже существует. Перезаписать?" "n"; then
|
||||
fail "Установка отменена. Отредактируйте .env вручную."
|
||||
fi
|
||||
cp .env ".env.bak.$(date +%s)" 2>/dev/null || true
|
||||
warn "Старая копия: .env.bak.*"
|
||||
fi
|
||||
|
||||
write_env ".env"
|
||||
ok "Создан .env"
|
||||
|
||||
echo ""
|
||||
info "=== Запуск контейнеров ==="
|
||||
DC="docker compose"
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
DC="sudo docker compose"
|
||||
fi
|
||||
|
||||
$DC pull db 2>/dev/null || true
|
||||
$DC up -d --build
|
||||
|
||||
echo ""
|
||||
sleep 3
|
||||
$DC ps
|
||||
|
||||
echo ""
|
||||
ok "Установка завершена!"
|
||||
echo ""
|
||||
echo " Каталог: $INSTALL_DIR"
|
||||
echo " Логи бота: $DC logs -f bot"
|
||||
echo " Логи БД: $DC logs -f db"
|
||||
echo " Перезапуск: $DC up -d --build"
|
||||
echo ""
|
||||
echo " В Telegram (аккаунт админа $TELEGRAM_ADMIN_ID):"
|
||||
echo " /start"
|
||||
echo " /admin check"
|
||||
echo " /admin squads"
|
||||
echo " /admin user"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,407 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telegramvpn/internal/db"
|
||||
"telegramvpn/internal/remnawave"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,36}$`)
|
||||
|
||||
func (h *Handler) handleAdminUsersSubcommand(chatID, adminID int64, args []string) {
|
||||
switch {
|
||||
case len(args) == 0 || args[0] == "help":
|
||||
h.sendText(chatID, "Пользователи Remnawave:\n\n"+
|
||||
"/admin user — мастер создания\n"+
|
||||
"/admin user <логин> [дней] — быстрое создание\n"+
|
||||
"/admin squads — список сквадов\n"+
|
||||
"/admin assign <логин> — назначить сквады (мастер)\n"+
|
||||
"/admin cancel — отменить мастер")
|
||||
case args[0] == "cancel", args[0] == "отмена":
|
||||
_ = h.database.ClearWizard(context.Background(), adminID)
|
||||
h.sendText(chatID, "Мастер отменён.")
|
||||
case args[0] == "squads", args[0] == "сквады":
|
||||
h.sendSquadsList(chatID)
|
||||
case args[0] == "user", args[0] == "пользователь":
|
||||
if len(args) >= 2 {
|
||||
days := h.cfg.DefaultUserDays
|
||||
if len(args) >= 3 {
|
||||
if d, err := strconv.Atoi(args[2]); err == nil && d > 0 {
|
||||
days = d
|
||||
}
|
||||
}
|
||||
h.quickCreateUser(chatID, adminID, args[1], days)
|
||||
} else {
|
||||
h.startUserWizard(chatID, adminID)
|
||||
}
|
||||
case args[0] == "assign", args[0] == "сквад":
|
||||
if len(args) < 2 {
|
||||
h.sendText(chatID, "Укажите логин: /admin assign username")
|
||||
return
|
||||
}
|
||||
h.startAssignWizard(chatID, adminID, args[1])
|
||||
default:
|
||||
h.sendAdminHelp(chatID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) startUserWizard(chatID, adminID int64) {
|
||||
ctx := context.Background()
|
||||
data := db.WizardData{"mode": "create"}
|
||||
_ = h.database.SetWizard(ctx, adminID, db.StepAwaitUsername, data)
|
||||
h.sendText(chatID, "Создание пользователя.\n\nВведите логин (3–36 символов, a-z, 0-9, _, -):\n\n/admin cancel — отмена")
|
||||
}
|
||||
|
||||
func (h *Handler) startAssignWizard(chatID, adminID int64, username string) {
|
||||
ctx := context.Background()
|
||||
data := db.WizardData{
|
||||
"mode": "assign",
|
||||
"username": username,
|
||||
}
|
||||
_ = h.database.SetWizard(ctx, adminID, db.StepPickExternalSquad, data)
|
||||
h.sendExternalSquadPicker(chatID, data)
|
||||
}
|
||||
|
||||
func (h *Handler) handleWizardMessage(chatID, adminID int64, text string) bool {
|
||||
ctx := context.Background()
|
||||
w, err := h.database.GetWizard(ctx, adminID)
|
||||
if err != nil || w == nil || w.Step == db.StepIdle || w.Step == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
switch w.Step {
|
||||
case db.StepAwaitUsername:
|
||||
if !usernameRe.MatchString(text) {
|
||||
h.sendText(chatID, "Неверный логин. Допустимы: a-z, 0-9, _, - (3–36 символов).")
|
||||
return true
|
||||
}
|
||||
w.Data.Set("username", text)
|
||||
_ = h.database.SetWizard(ctx, adminID, db.StepAwaitDays, w.Data)
|
||||
h.sendText(chatID, fmt.Sprintf("Срок подписки в днях (по умолчанию %d):", h.cfg.DefaultUserDays))
|
||||
return true
|
||||
|
||||
case db.StepAwaitDays:
|
||||
days := h.cfg.DefaultUserDays
|
||||
if text != "" {
|
||||
if d, err := strconv.Atoi(text); err != nil || d <= 0 {
|
||||
h.sendText(chatID, "Введите число дней больше 0.")
|
||||
return true
|
||||
} else {
|
||||
days = d
|
||||
}
|
||||
}
|
||||
w.Data.Set("days", days)
|
||||
_ = h.database.SetWizard(ctx, adminID, db.StepPickExternalSquad, w.Data)
|
||||
h.sendExternalSquadPicker(chatID, w.Data)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) handleWizardCallback(cq *tgbotapi.CallbackQuery) bool {
|
||||
if !strings.HasPrefix(cq.Data, "wz:") {
|
||||
return false
|
||||
}
|
||||
chatID := cq.Message.Chat.ID
|
||||
adminID := cq.From.ID
|
||||
ctx := context.Background()
|
||||
|
||||
w, err := h.database.GetWizard(ctx, adminID)
|
||||
if err != nil || w == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
parts := strings.Split(cq.Data, ":")
|
||||
if len(parts) < 2 {
|
||||
return true
|
||||
}
|
||||
|
||||
switch parts[1] {
|
||||
case "ext":
|
||||
if len(parts) < 3 {
|
||||
return true
|
||||
}
|
||||
if parts[2] == "skip" {
|
||||
w.Data.Set("external_squad", "")
|
||||
} else {
|
||||
w.Data.Set("external_squad", parts[2])
|
||||
}
|
||||
_ = h.database.SetWizard(ctx, adminID, db.StepPickInternalSquads, w.Data)
|
||||
h.sendInternalSquadPicker(chatID, w.Data)
|
||||
|
||||
case "int":
|
||||
if len(parts) < 3 {
|
||||
return true
|
||||
}
|
||||
if parts[2] == "done" {
|
||||
_ = h.database.SetWizard(ctx, adminID, db.StepConfirm, w.Data)
|
||||
h.sendConfirm(chatID, w.Data)
|
||||
return true
|
||||
}
|
||||
w.Data.ToggleUUID("internal_squads", parts[2])
|
||||
_ = h.database.SetWizard(ctx, adminID, db.StepPickInternalSquads, w.Data)
|
||||
h.sendInternalSquadPicker(chatID, w.Data)
|
||||
|
||||
case "ok":
|
||||
h.finishWizard(chatID, adminID, w.Data)
|
||||
|
||||
case "no":
|
||||
_ = h.database.ClearWizard(ctx, adminID)
|
||||
h.sendText(chatID, "Отменено.")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) sendSquadsList(chatID int64) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ext, err1 := h.panel.ListExternalSquads(ctx)
|
||||
ints, err2 := h.panel.ListInternalSquads(ctx)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("Сквады Remnawave:\n\n")
|
||||
|
||||
b.WriteString("External:\n")
|
||||
if err1 != nil {
|
||||
b.WriteString(" ошибка: " + err1.Error() + "\n")
|
||||
} else if len(ext) == 0 {
|
||||
b.WriteString(" (пусто)\n")
|
||||
} else {
|
||||
for _, s := range ext {
|
||||
b.WriteString(fmt.Sprintf(" • %s\n %s\n", s.Name, s.UUID))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\nInternal:\n")
|
||||
if err2 != nil {
|
||||
b.WriteString(" ошибка: " + err2.Error() + "\n")
|
||||
} else if len(ints) == 0 {
|
||||
b.WriteString(" (пусто)\n")
|
||||
} else {
|
||||
for _, s := range ints {
|
||||
b.WriteString(fmt.Sprintf(" • %s\n %s\n", s.Name, s.UUID))
|
||||
}
|
||||
}
|
||||
h.sendText(chatID, b.String())
|
||||
}
|
||||
|
||||
func (h *Handler) sendExternalSquadPicker(chatID int64, data db.WizardData) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
squads, err := h.panel.ListExternalSquads(ctx)
|
||||
if err != nil {
|
||||
h.sendText(chatID, "Не удалось загрузить external squads: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var rows [][]tgbotapi.InlineKeyboardButton
|
||||
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("⏭ Без external squad", "wz:ext:skip"),
|
||||
))
|
||||
for _, s := range squads {
|
||||
label := s.Name
|
||||
if len(label) > 40 {
|
||||
label = label[:40]
|
||||
}
|
||||
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(label, "wz:ext:"+s.UUID),
|
||||
))
|
||||
}
|
||||
msg := tgbotapi.NewMessage(chatID, "Выберите External Squad:")
|
||||
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(rows...)
|
||||
h.send(msg)
|
||||
}
|
||||
|
||||
func (h *Handler) sendInternalSquadPicker(chatID int64, data db.WizardData) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
squads, err := h.panel.ListInternalSquads(ctx)
|
||||
if err != nil {
|
||||
h.sendText(chatID, "Не удалось загрузить internal squads: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
selected := map[string]bool{}
|
||||
for _, id := range data.StringSlice("internal_squads") {
|
||||
selected[id] = true
|
||||
}
|
||||
|
||||
var rows [][]tgbotapi.InlineKeyboardButton
|
||||
for _, s := range squads {
|
||||
mark := "☐"
|
||||
if selected[s.UUID] {
|
||||
mark = "☑"
|
||||
}
|
||||
label := fmt.Sprintf("%s %s", mark, s.Name)
|
||||
if len(label) > 60 {
|
||||
label = label[:60]
|
||||
}
|
||||
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(label, "wz:int:"+s.UUID),
|
||||
))
|
||||
}
|
||||
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("✅ Готово", "wz:int:done"),
|
||||
))
|
||||
|
||||
msg := tgbotapi.NewMessage(chatID, "Выберите Internal Squads (можно несколько), затем «Готово»:")
|
||||
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(rows...)
|
||||
h.send(msg)
|
||||
}
|
||||
|
||||
func (h *Handler) sendConfirm(chatID int64, data db.WizardData) {
|
||||
ext := data.String("external_squad")
|
||||
ints := data.StringSlice("internal_squads")
|
||||
text := fmt.Sprintf(
|
||||
"Подтвердите:\n\nЛогин: %s\nДней: %d\nExternal: %s\nInternal: %d шт.\n",
|
||||
data.String("username"), data.Int("days"), squadLabel(ext), len(ints),
|
||||
)
|
||||
if data.String("mode") == "assign" {
|
||||
text = fmt.Sprintf(
|
||||
"Назначить сквады пользователю %s\n\nExternal: %s\nInternal: %d шт.\n",
|
||||
data.String("username"), squadLabel(ext), len(ints),
|
||||
)
|
||||
}
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("✅ Да", "wz:ok"),
|
||||
tgbotapi.NewInlineKeyboardButtonData("❌ Нет", "wz:no"),
|
||||
),
|
||||
)
|
||||
h.send(msg)
|
||||
}
|
||||
|
||||
func (h *Handler) finishWizard(chatID, adminID int64, data db.WizardData) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ext := data.String("external_squad")
|
||||
var extPtr *string
|
||||
if ext != "" {
|
||||
extPtr = &ext
|
||||
}
|
||||
ints := data.StringSlice("internal_squads")
|
||||
if len(ints) == 0 && len(h.cfg.DefaultInternalSquadUUIDs) > 0 {
|
||||
ints = h.cfg.DefaultInternalSquadUUIDs
|
||||
}
|
||||
if extPtr == nil && h.cfg.DefaultExternalSquadUUID != "" {
|
||||
e := h.cfg.DefaultExternalSquadUUID
|
||||
extPtr = &e
|
||||
}
|
||||
|
||||
mode := data.String("mode")
|
||||
if mode == "assign" {
|
||||
u, err := h.panel.AssignSquads(ctx, remnawave.AssignSquadsInput{
|
||||
Username: data.String("username"),
|
||||
ExternalSquadUUID: extPtr,
|
||||
ActiveInternalSquads: ints,
|
||||
})
|
||||
_ = h.database.ClearWizard(ctx, adminID)
|
||||
if err != nil {
|
||||
h.sendText(chatID, "Ошибка назначения сквадов: "+err.Error())
|
||||
return
|
||||
}
|
||||
h.sendText(chatID, fmt.Sprintf("✅ Сквады назначены пользователю %s\nUUID: %s", u.Username, u.UUID))
|
||||
return
|
||||
}
|
||||
|
||||
days := data.Int("days")
|
||||
if days <= 0 {
|
||||
days = h.cfg.DefaultUserDays
|
||||
}
|
||||
var tgID *int64
|
||||
// при создании из мастера админом telegramId не обязателен
|
||||
|
||||
u, err := h.panel.CreateUser(ctx, remnawave.CreateUserInput{
|
||||
Username: data.String("username"),
|
||||
ExpireAt: db.DefaultExpireAt(days),
|
||||
TelegramID: tgID,
|
||||
ExternalSquadUUID: extPtr,
|
||||
ActiveInternalSquads: ints,
|
||||
Description: "created via tgvpn bot",
|
||||
})
|
||||
_ = h.database.ClearWizard(ctx, adminID)
|
||||
if err != nil {
|
||||
h.sendText(chatID, "Ошибка создания: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
vpn := db.VPNUser{
|
||||
RemnawaveUUID: u.UUID,
|
||||
RemnawaveUsername: u.Username,
|
||||
ExternalSquadUUID: extPtr,
|
||||
InternalSquadUUIDs: ints,
|
||||
ExpireAt: &u.ExpireAt,
|
||||
}
|
||||
if err := h.database.SaveVPNUser(ctx, vpn); err != nil {
|
||||
log.Printf("save vpn user: %v", err)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("✅ Пользователь создан\n\nЛогин: %s\nUUID: %s\nИстекает: %s",
|
||||
u.Username, u.UUID, u.ExpireAt.Format("2006-01-02"))
|
||||
if u.SubscriptionURL != "" {
|
||||
text += "\nПодписка: " + u.SubscriptionURL
|
||||
} else if u.ShortUUID != "" && h.cfg.RemnawaveSubscription != "" {
|
||||
text += "\nПодписка: " + h.cfg.RemnawaveSubscription + "/" + u.ShortUUID
|
||||
}
|
||||
h.sendText(chatID, text)
|
||||
}
|
||||
|
||||
func (h *Handler) quickCreateUser(chatID, adminID int64, username string, days int) {
|
||||
if !usernameRe.MatchString(username) {
|
||||
h.sendText(chatID, "Неверный логин.")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var extPtr *string
|
||||
if h.cfg.DefaultExternalSquadUUID != "" {
|
||||
e := h.cfg.DefaultExternalSquadUUID
|
||||
extPtr = &e
|
||||
}
|
||||
ints := h.cfg.DefaultInternalSquadUUIDs
|
||||
|
||||
u, err := h.panel.CreateUser(ctx, remnawave.CreateUserInput{
|
||||
Username: username,
|
||||
ExpireAt: db.DefaultExpireAt(days),
|
||||
ExternalSquadUUID: extPtr,
|
||||
ActiveInternalSquads: ints,
|
||||
Description: "created via tgvpn bot",
|
||||
})
|
||||
if err != nil {
|
||||
h.sendText(chatID, "Ошибка: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = h.database.SaveVPNUser(ctx, db.VPNUser{
|
||||
RemnawaveUUID: u.UUID,
|
||||
RemnawaveUsername: u.Username,
|
||||
ExternalSquadUUID: extPtr,
|
||||
InternalSquadUUIDs: ints,
|
||||
ExpireAt: &u.ExpireAt,
|
||||
})
|
||||
h.sendText(chatID, fmt.Sprintf("✅ %s создан до %s", u.Username, u.ExpireAt.Format("2006-01-02")))
|
||||
}
|
||||
|
||||
func squadLabel(uuid string) string {
|
||||
if uuid == "" {
|
||||
return "—"
|
||||
}
|
||||
if len(uuid) > 12 {
|
||||
return uuid[:8] + "…"
|
||||
}
|
||||
return uuid
|
||||
}
|
||||
+178
-109
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"telegramvpn/internal/config"
|
||||
"telegramvpn/internal/db"
|
||||
"telegramvpn/internal/remnawave"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
@@ -16,33 +17,36 @@ import (
|
||||
const docsURL = "https://docs.rw/"
|
||||
|
||||
type Handler struct {
|
||||
cfg *config.Config
|
||||
api *tgbotapi.BotAPI
|
||||
panel *remnawave.Client
|
||||
admin int64
|
||||
cfg *config.Config
|
||||
api *tgbotapi.BotAPI
|
||||
panel *remnawave.Client
|
||||
database *db.DB
|
||||
admin int64
|
||||
}
|
||||
|
||||
func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI) *Handler {
|
||||
func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI, database *db.DB) *Handler {
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
api: api,
|
||||
panel: remnawave.NewClient(cfg.RemnawaveURL, cfg.RemnawaveToken, cfg.RemnawaveCaddy),
|
||||
admin: cfg.TelegramAdminID,
|
||||
cfg: cfg,
|
||||
api: api,
|
||||
panel: remnawave.NewClient(cfg.RemnawavePanelURL, cfg.RemnawaveAPIToken, cfg.CaddyAuthAPIToken),
|
||||
database: database,
|
||||
admin: cfg.TelegramAdminID,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterCommands() {
|
||||
commands := []tgbotapi.BotCommand{
|
||||
public := []tgbotapi.BotCommand{
|
||||
{Command: "start", Description: "Начать"},
|
||||
{Command: "admin", Description: "Админ-меню Remnawave (панель 1)"},
|
||||
{Command: "config", Description: "Получить VPN-конфиг"},
|
||||
}
|
||||
if _, err := h.api.Request(tgbotapi.NewSetMyCommands(public...)); err != nil {
|
||||
log.Printf("команды (все пользователи): %v", err)
|
||||
}
|
||||
|
||||
admin := append(public, tgbotapi.BotCommand{Command: "admin", Description: "Админ-меню"})
|
||||
scope := tgbotapi.BotCommandScope{Type: "chat", ChatID: h.admin}
|
||||
cfg := tgbotapi.SetMyCommandsConfig{
|
||||
Commands: commands,
|
||||
Scope: &scope,
|
||||
}
|
||||
if _, err := h.api.Request(cfg); err != nil {
|
||||
log.Printf("не удалось зарегистрировать команды для админа: %v", err)
|
||||
if _, err := h.api.Request(tgbotapi.SetMyCommandsConfig{Commands: admin, Scope: &scope}); err != nil {
|
||||
log.Printf("команды (админ): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,26 +65,36 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) {
|
||||
|
||||
switch {
|
||||
case text == "/start":
|
||||
h.sendStart(chatID, userID, update.Message.From.FirstName)
|
||||
h.sendStart(chatID, userID, update.Message.From.FirstName, update.Message.From.UserName)
|
||||
case text == "/config", text == "/getconfig":
|
||||
h.handleUserConfig(chatID, userID)
|
||||
case strings.HasPrefix(text, "/admin"):
|
||||
h.handleAdminCommand(chatID, userID, text)
|
||||
case strings.HasPrefix(text, "/"):
|
||||
h.sendText(chatID, "Неизвестная команда. Для начала — /start")
|
||||
default:
|
||||
if h.isAdmin(userID) {
|
||||
switch text {
|
||||
case "📋 Конфиг панели":
|
||||
h.sendPanelConfig(chatID)
|
||||
return
|
||||
case "🔌 Проверить панель":
|
||||
h.sendPanelCheck(chatID)
|
||||
return
|
||||
case "◀️ Выйти из админки":
|
||||
h.sendText(chatID, "Админ-меню закрыто. /admin — снова открыть.")
|
||||
return
|
||||
}
|
||||
if h.isAdmin(userID) && h.handleWizardMessage(chatID, userID, text) {
|
||||
return
|
||||
}
|
||||
h.sendText(chatID, "Напишите /start, чтобы начать.")
|
||||
switch text {
|
||||
case userHomeLabel(), "/menu":
|
||||
h.sendUserMenu(chatID, userID, update.Message.From.FirstName, update.Message.From.UserName)
|
||||
return
|
||||
case adminPanelLabel(), "🛠 Админ-меню":
|
||||
if h.isAdmin(userID) {
|
||||
h.sendAdminMenu(chatID)
|
||||
}
|
||||
return
|
||||
}
|
||||
if h.isUserConfigButtonText(text) {
|
||||
h.handleUserConfig(chatID, userID)
|
||||
return
|
||||
}
|
||||
// Старые подписи reply-клавиатуры (если остались у пользователя)
|
||||
if h.isAdmin(userID) && h.handleLegacyAdminReply(chatID, userID, text) {
|
||||
return
|
||||
}
|
||||
h.sendText(chatID, "Напишите /start или нажмите 🏠 Главная в меню.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +115,8 @@ func (h *Handler) handleAdminCommand(chatID, userID int64, text string) {
|
||||
h.sendPanelCheck(chatID)
|
||||
case "config", "конфиг":
|
||||
h.sendPanelConfig(chatID)
|
||||
case "user", "пользователь", "squads", "сквады", "assign", "сквад", "cancel", "отмена", "help":
|
||||
h.handleAdminUsersSubcommand(chatID, userID, args[2:])
|
||||
default:
|
||||
h.sendAdminHelp(chatID)
|
||||
}
|
||||
@@ -112,20 +128,54 @@ func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) {
|
||||
log.Printf("callback answer: %v", err)
|
||||
}
|
||||
|
||||
if !h.isAdmin(cq.From.ID) {
|
||||
h.editOrSend(cq.Message.Chat.ID, cq.Message.MessageID, "Нет доступа.")
|
||||
chatID := cq.Message.Chat.ID
|
||||
userID := cq.From.ID
|
||||
|
||||
switch cq.Data {
|
||||
case cbUserConfig:
|
||||
h.handleUserConfig(chatID, userID)
|
||||
return
|
||||
case cbUserHome:
|
||||
h.sendUserMenu(chatID, userID, cq.From.FirstName, cq.From.UserName)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(cq.Data, "wz:") {
|
||||
if !h.isAdmin(userID) {
|
||||
h.callbackDenied(cq)
|
||||
return
|
||||
}
|
||||
if h.handleWizardCallback(cq) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !h.isAdmin(userID) {
|
||||
h.callbackDenied(cq)
|
||||
return
|
||||
}
|
||||
|
||||
switch cq.Data {
|
||||
case "admin:config":
|
||||
h.sendPanelConfig(cq.Message.Chat.ID)
|
||||
case "admin:check":
|
||||
h.sendPanelCheck(cq.Message.Chat.ID)
|
||||
case "admin:menu":
|
||||
h.sendAdminMenu(cq.Message.Chat.ID)
|
||||
case cbAdminUser:
|
||||
h.startUserWizard(chatID, userID)
|
||||
case cbAdminSquads:
|
||||
h.sendSquadsList(chatID)
|
||||
case cbAdminConfig:
|
||||
h.sendPanelConfig(chatID)
|
||||
case cbAdminCheck:
|
||||
h.sendPanelCheck(chatID)
|
||||
case cbAdminMenu:
|
||||
h.sendAdminMenu(chatID)
|
||||
default:
|
||||
h.editOrSend(cq.Message.Chat.ID, cq.Message.MessageID, "Неизвестное действие.")
|
||||
h.editOrSend(chatID, cq.Message.MessageID, "Неизвестное действие.")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) callbackDenied(cq *tgbotapi.CallbackQuery) {
|
||||
cb := tgbotapi.NewCallback(cq.ID, "Нет доступа")
|
||||
cb.ShowAlert = true
|
||||
if _, err := h.api.Request(cb); err != nil {
|
||||
log.Printf("callback alert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,65 +183,120 @@ func (h *Handler) isAdmin(userID int64) bool {
|
||||
return userID == h.admin
|
||||
}
|
||||
|
||||
func (h *Handler) sendStart(chatID, userID int64, firstName string) {
|
||||
func (h *Handler) sendStart(chatID, userID int64, firstName, tgUsername string) {
|
||||
h.sendUserMenu(chatID, userID, firstName, tgUsername)
|
||||
}
|
||||
|
||||
func (h *Handler) sendUserMenu(chatID, userID int64, firstName, tgUsername string) {
|
||||
ctx := context.Background()
|
||||
_ = h.database.UpsertTelegramUser(ctx, userID, tgUsername, firstName)
|
||||
|
||||
h.dismissReplyKeyboard(chatID)
|
||||
|
||||
name := firstName
|
||||
if name == "" {
|
||||
name = "друг"
|
||||
}
|
||||
text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот на базе панели Remnawave.", name)
|
||||
days := trialDays(h.cfg)
|
||||
text := fmt.Sprintf(
|
||||
"👋 Привет, %s!\n\n"+
|
||||
"🔐 Trial VPN — %d дн.\n"+
|
||||
"Нажмите кнопку ниже или /config\n\n"+
|
||||
"Импорт: V2rayNG, Hiddify, Streisand и др.",
|
||||
name, days,
|
||||
)
|
||||
if h.isAdmin(userID) {
|
||||
text += "\n\n/admin — админ-меню\n/admin check — проверка API и подписки"
|
||||
text += "\n\nВы администратор: кнопка «🛠 Админ-панель» или /admin"
|
||||
}
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
if h.isAdmin(userID) {
|
||||
msg.ReplyMarkup = adminReplyKeyboard()
|
||||
}
|
||||
msg.ReplyMarkup = userMenuKeyboard(h.cfg, userID, h.admin)
|
||||
h.send(msg)
|
||||
}
|
||||
|
||||
func (h *Handler) dismissReplyKeyboard(chatID int64) {
|
||||
rm := tgbotapi.NewMessage(chatID, "\u200b")
|
||||
rm.ReplyMarkup = tgbotapi.NewRemoveKeyboard(true)
|
||||
rm.DisableNotification = true
|
||||
if _, err := h.api.Send(rm); err != nil {
|
||||
log.Printf("remove reply keyboard: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) isUserConfigButtonText(text string) bool {
|
||||
return text == userConfigLabel(h.cfg) ||
|
||||
strings.HasPrefix(text, "🔐 ") ||
|
||||
strings.HasPrefix(text, "📲 Получить конфиг")
|
||||
}
|
||||
|
||||
func (h *Handler) handleLegacyAdminReply(chatID, userID int64, text string) bool {
|
||||
switch text {
|
||||
case "📋 Конфиг панели", "⚙️ Настройки":
|
||||
h.sendPanelConfig(chatID)
|
||||
case "🔌 Проверить панель", "🔌 Проверка API":
|
||||
h.sendPanelCheck(chatID)
|
||||
case "👤 Создать пользователя", "👤 Новый пользователь":
|
||||
h.startUserWizard(chatID, userID)
|
||||
case "📡 Сквады":
|
||||
h.sendSquadsList(chatID)
|
||||
case "◀️ Выйти из админки":
|
||||
h.sendUserMenu(chatID, userID, "", "")
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) sendAdminMenu(chatID int64) {
|
||||
text := fmt.Sprintf(
|
||||
"🛠 *Админ-меню* — %s\n\n"+
|
||||
"Команды:\n"+
|
||||
"• /admin — это меню\n"+
|
||||
"• /admin check — проверка панели, API и подписки\n"+
|
||||
"• /admin config — конфиг панели\n\n"+
|
||||
"Или кнопки ниже.",
|
||||
"🛠 *Админ-панель* — %s\n\n"+
|
||||
"• /admin check — проверка API\n"+
|
||||
"• /admin user — новый пользователь\n"+
|
||||
"• /admin squads — сквады\n"+
|
||||
"• /admin assign — назначить сквады\n\n"+
|
||||
"🏠 Главная — меню пользователя",
|
||||
escapeMarkdown(h.cfg.RemnawaveName),
|
||||
)
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ParseMode = "Markdown"
|
||||
msg.ReplyMarkup = adminInlineKeyboard()
|
||||
msg.ReplyMarkup = adminMenuKeyboard()
|
||||
h.send(msg)
|
||||
}
|
||||
|
||||
func (h *Handler) sendAdminHelp(chatID int64) {
|
||||
h.sendText(chatID, "Неизвестный аргумент.\n\n/admin — меню\n/admin check — проверка\n/admin config — конфиг")
|
||||
h.sendText(chatID, "Команды:\n/admin — меню\n/admin check\n/admin config\n/admin user\n/admin squads\n/admin assign <логин>")
|
||||
}
|
||||
|
||||
func (h *Handler) sendPanelConfig(chatID int64) {
|
||||
subURL := h.cfg.RemnawaveSubscription
|
||||
if subURL == "" {
|
||||
subURL = "не задан"
|
||||
subURL = "не задана (опционально)"
|
||||
}
|
||||
caddy := h.cfg.CaddyAuthAPIToken
|
||||
if caddy == "" {
|
||||
caddy = "не задан"
|
||||
} else {
|
||||
caddy = maskSecret(caddy)
|
||||
}
|
||||
text := fmt.Sprintf(
|
||||
"⚙️ *%s* (Remnawave)\n\n"+
|
||||
"• URL панели: `%s`\n"+
|
||||
"• URL подписки: `%s`\n"+
|
||||
"• API token: `%s`\n"+
|
||||
"• Caddy token: %s\n\n"+
|
||||
"Токен API: панель → *Settings → API Tokens*.\n"+
|
||||
"Документация: %s",
|
||||
escapeMarkdown(h.cfg.RemnawaveName),
|
||||
escapeMarkdown(h.cfg.RemnawaveURL),
|
||||
escapeMarkdown(subURL),
|
||||
escapeMarkdown(maskSecret(h.cfg.RemnawaveToken)),
|
||||
caddyStatus(h.cfg.RemnawaveCaddy),
|
||||
docsURL,
|
||||
"⚙️ %s (Remnawave)\n\n"+
|
||||
"REMNAWAVE_PANEL_URL:\n%s\n"+
|
||||
"(API: %s/api/... + Bearer REMNAWAVE_API_TOKEN)\n\n"+
|
||||
"REMNAWAVE_SUBSCRIPTION_URL (опц.):\n%s\n\n"+
|
||||
"REMNAWAVE_API_TOKEN: %s\n"+
|
||||
"CADDY_AUTH_API_TOKEN: %s\n\n"+
|
||||
"Токен: Remnawave Settings → API Tokens\n"+
|
||||
"Док: %s",
|
||||
h.cfg.RemnawaveName,
|
||||
h.cfg.RemnawavePanelURL,
|
||||
h.cfg.RemnawavePanelURL,
|
||||
subURL,
|
||||
maskSecret(h.cfg.RemnawaveAPIToken),
|
||||
caddy,
|
||||
"https://docs.rw/docs/install/subscription-page/bundled",
|
||||
)
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ParseMode = "Markdown"
|
||||
msg.ReplyMarkup = adminInlineKeyboard()
|
||||
msg.ReplyMarkup = adminContextKeyboard()
|
||||
h.send(msg)
|
||||
}
|
||||
|
||||
@@ -202,47 +307,11 @@ func (h *Handler) sendPanelCheck(chatID int64) {
|
||||
defer cancel()
|
||||
|
||||
report := h.panel.FullCheck(ctx, h.cfg.RemnawaveSubscription)
|
||||
text := remnawave.FormatReport(
|
||||
report,
|
||||
escapeMarkdown(h.cfg.RemnawaveName),
|
||||
escapeMarkdown(h.cfg.RemnawaveURL),
|
||||
)
|
||||
text := remnawave.FormatReport(report, h.cfg.RemnawaveName)
|
||||
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ParseMode = "Markdown"
|
||||
msg.ReplyMarkup = adminInlineKeyboard()
|
||||
if err := h.sendReturnErr(msg); err != nil {
|
||||
msg.ParseMode = ""
|
||||
h.send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
|
||||
return tgbotapi.NewInlineKeyboardMarkup(
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить (API+подписка)", "admin:check"),
|
||||
tgbotapi.NewInlineKeyboardButtonData("📋 Конфиг", "admin:config"),
|
||||
),
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonURL("📖 Документация", docsURL),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func adminReplyKeyboard() tgbotapi.ReplyKeyboardMarkup {
|
||||
return tgbotapi.ReplyKeyboardMarkup{
|
||||
Keyboard: [][]tgbotapi.KeyboardButton{
|
||||
{
|
||||
tgbotapi.NewKeyboardButton("🔌 Проверить панель"),
|
||||
tgbotapi.NewKeyboardButton("📋 Конфиг панели"),
|
||||
},
|
||||
{
|
||||
tgbotapi.NewKeyboardButton("◀️ Выйти из админки"),
|
||||
},
|
||||
},
|
||||
ResizeKeyboard: true,
|
||||
OneTimeKeyboard: false,
|
||||
}
|
||||
msg.ReplyMarkup = adminContextKeyboard()
|
||||
h.send(msg)
|
||||
}
|
||||
|
||||
func (h *Handler) sendText(chatID int64, text string) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"telegramvpn/internal/config"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
cbUserConfig = "user:config"
|
||||
cbUserHome = "user:home"
|
||||
cbAdminMenu = "admin:menu"
|
||||
cbAdminUser = "admin:user"
|
||||
cbAdminSquads = "admin:squads"
|
||||
cbAdminCheck = "admin:check"
|
||||
cbAdminConfig = "admin:config"
|
||||
)
|
||||
|
||||
func trialDays(cfg *config.Config) int {
|
||||
d := cfg.TrialUserDays
|
||||
if d <= 0 {
|
||||
return 1
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func userConfigLabel(cfg *config.Config) string {
|
||||
return fmt.Sprintf("🔐 Подключить VPN · %d дн.", trialDays(cfg))
|
||||
}
|
||||
|
||||
func userHomeLabel() string {
|
||||
return "🏠 Главная"
|
||||
}
|
||||
|
||||
func adminPanelLabel() string {
|
||||
return "🛠 Админ-панель"
|
||||
}
|
||||
|
||||
// userMenuKeyboard — главное меню пользователя (и выход из админки).
|
||||
func userMenuKeyboard(cfg *config.Config, userID, adminID int64) tgbotapi.InlineKeyboardMarkup {
|
||||
rows := [][]tgbotapi.InlineKeyboardButton{
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(userConfigLabel(cfg), cbUserConfig),
|
||||
),
|
||||
}
|
||||
if userID == adminID {
|
||||
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(adminPanelLabel(), cbAdminMenu),
|
||||
))
|
||||
}
|
||||
return tgbotapi.NewInlineKeyboardMarkup(rows...)
|
||||
}
|
||||
|
||||
// configResultKeyboard — после выдачи конфига: ссылка + возврат в меню.
|
||||
func configResultKeyboard(cfg *config.Config, userID, adminID int64, subscriptionURL string) tgbotapi.InlineKeyboardMarkup {
|
||||
var rows [][]tgbotapi.InlineKeyboardButton
|
||||
if subscriptionURL != "" {
|
||||
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonURL("🔗 Открыть подписку", subscriptionURL),
|
||||
))
|
||||
}
|
||||
rows = append(rows,
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(userConfigLabel(cfg), cbUserConfig),
|
||||
),
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(userHomeLabel(), cbUserHome),
|
||||
),
|
||||
)
|
||||
if userID == adminID {
|
||||
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(adminPanelLabel(), cbAdminMenu),
|
||||
))
|
||||
}
|
||||
return tgbotapi.NewInlineKeyboardMarkup(rows...)
|
||||
}
|
||||
|
||||
// adminMenuKeyboard — панель администратора.
|
||||
func adminMenuKeyboard() tgbotapi.InlineKeyboardMarkup {
|
||||
return tgbotapi.NewInlineKeyboardMarkup(
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("👤 Новый пользователь", cbAdminUser),
|
||||
tgbotapi.NewInlineKeyboardButtonData("📡 Сквады", cbAdminSquads),
|
||||
),
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("🔌 Проверка API", cbAdminCheck),
|
||||
tgbotapi.NewInlineKeyboardButtonData("⚙️ Настройки", cbAdminConfig),
|
||||
),
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonURL("📖 Remnawave Docs", docsURL),
|
||||
),
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(userHomeLabel(), cbUserHome),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// adminContextKeyboard — под ответами админки (проверка, конфиг и т.д.).
|
||||
func adminContextKeyboard() tgbotapi.InlineKeyboardMarkup {
|
||||
return tgbotapi.NewInlineKeyboardMarkup(
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("🛠 В админ-панель", cbAdminMenu),
|
||||
tgbotapi.NewInlineKeyboardButtonData(userHomeLabel(), cbUserHome),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"telegramvpn/internal/db"
|
||||
"telegramvpn/internal/remnawave"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func (h *Handler) handleUserConfig(chatID, telegramID int64) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
days := h.cfg.TrialUserDays
|
||||
if days <= 0 {
|
||||
days = 1
|
||||
}
|
||||
|
||||
existing, err := h.database.GetVPNByTelegramID(ctx, telegramID)
|
||||
if err != nil {
|
||||
h.sendText(chatID, "Ошибка базы данных. Попробуйте позже.")
|
||||
return
|
||||
}
|
||||
if existing != nil && existing.ExpireAt != nil && existing.ExpireAt.After(time.Now()) {
|
||||
link := h.resolveSubscriptionLink(ctx, existing.RemnawaveUsername, telegramID)
|
||||
h.sendConfigMessage(chatID, telegramID, days, existing.RemnawaveUsername, *existing.ExpireAt, link)
|
||||
return
|
||||
}
|
||||
|
||||
panelUser, err := h.panel.GetUserByTelegramID(ctx, telegramID)
|
||||
if err == nil && panelUser != nil && panelUser.ExpireAt.After(time.Now()) {
|
||||
link := panelUser.SubscriptionURL
|
||||
if link == "" {
|
||||
link = h.subscriptionLink(panelUser.ShortUUID)
|
||||
}
|
||||
_ = h.saveVPNFromPanel(ctx, telegramID, panelUser)
|
||||
h.sendConfigMessage(chatID, telegramID, days, panelUser.Username, panelUser.ExpireAt, link)
|
||||
return
|
||||
}
|
||||
|
||||
username := fmt.Sprintf("u%d", telegramID)
|
||||
var extPtr *string
|
||||
if h.cfg.DefaultExternalSquadUUID != "" {
|
||||
e := h.cfg.DefaultExternalSquadUUID
|
||||
extPtr = &e
|
||||
}
|
||||
ints := h.cfg.DefaultInternalSquadUUIDs
|
||||
tgID := telegramID
|
||||
|
||||
u, err := h.panel.CreateUser(ctx, remnawave.CreateUserInput{
|
||||
Username: username,
|
||||
ExpireAt: db.DefaultExpireAt(days),
|
||||
TelegramID: &tgID,
|
||||
ExternalSquadUUID: extPtr,
|
||||
ActiveInternalSquads: ints,
|
||||
Description: fmt.Sprintf("trial %d day via tgvpn bot", days),
|
||||
})
|
||||
if err != nil {
|
||||
h.sendText(chatID, "Не удалось создать доступ: "+err.Error()+"\n\nПопробуйте позже или напишите администратору.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.saveVPNFromPanel(ctx, telegramID, u); err != nil {
|
||||
log.Printf("save vpn user: %v", err)
|
||||
}
|
||||
|
||||
link := u.SubscriptionURL
|
||||
if link == "" {
|
||||
link = h.subscriptionLink(u.ShortUUID)
|
||||
}
|
||||
h.sendConfigMessage(chatID, telegramID, days, u.Username, u.ExpireAt, link)
|
||||
}
|
||||
|
||||
func (h *Handler) saveVPNFromPanel(ctx context.Context, telegramID int64, u *remnawave.PanelUser) error {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
var ext *string
|
||||
if h.cfg.DefaultExternalSquadUUID != "" {
|
||||
e := h.cfg.DefaultExternalSquadUUID
|
||||
ext = &e
|
||||
}
|
||||
return h.database.SaveVPNUser(ctx, db.VPNUser{
|
||||
TelegramID: &telegramID,
|
||||
RemnawaveUUID: u.UUID,
|
||||
RemnawaveUsername: u.Username,
|
||||
ExternalSquadUUID: ext,
|
||||
InternalSquadUUIDs: h.cfg.DefaultInternalSquadUUIDs,
|
||||
ExpireAt: &u.ExpireAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) resolveSubscriptionLink(ctx context.Context, username string, telegramID int64) string {
|
||||
if u, err := h.panel.GetUserByUsername(ctx, username); err == nil && u != nil {
|
||||
if u.SubscriptionURL != "" {
|
||||
return u.SubscriptionURL
|
||||
}
|
||||
return h.subscriptionLink(u.ShortUUID)
|
||||
}
|
||||
if u, err := h.panel.GetUserByTelegramID(ctx, telegramID); err == nil && u != nil {
|
||||
if u.SubscriptionURL != "" {
|
||||
return u.SubscriptionURL
|
||||
}
|
||||
return h.subscriptionLink(u.ShortUUID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *Handler) subscriptionLink(shortUUID string) string {
|
||||
if shortUUID != "" && h.cfg.RemnawaveSubscription != "" {
|
||||
return h.cfg.RemnawaveSubscription + "/" + shortUUID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *Handler) sendConfigMessage(chatID, telegramID int64, days int, username string, expireAt time.Time, link string) {
|
||||
text := fmt.Sprintf(
|
||||
"✅ VPN готов\n\n"+
|
||||
"⏱ Срок: %d дн. (до %s)\n"+
|
||||
"👤 Логин: %s\n",
|
||||
days,
|
||||
expireAt.Local().Format("02.01.2006 15:04"),
|
||||
username,
|
||||
)
|
||||
if link != "" {
|
||||
text += "\n🔗 Ссылка подписки — кнопка ниже или:\n" + link
|
||||
} else {
|
||||
text += "\n\n⚠️ Ссылка не настроена — REMNAWAVE_SUBSCRIPTION_URL в .env"
|
||||
}
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ReplyMarkup = configResultKeyboard(h.cfg, telegramID, h.admin, link)
|
||||
h.send(msg)
|
||||
}
|
||||
|
||||
+67
-20
@@ -7,15 +7,23 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// См. официальную схему env: https://docs.rw/docs/install/subscription-page/bundled
|
||||
// REMNAWAVE_PANEL_URL + REMNAWAVE_API_TOKEN (+ опционально CADDY_AUTH_API_TOKEN)
|
||||
|
||||
type Config struct {
|
||||
BotToken string
|
||||
BotDebug bool
|
||||
TelegramAdminID int64
|
||||
BotToken string
|
||||
BotDebug bool
|
||||
TelegramAdminID int64
|
||||
RemnawaveName string
|
||||
RemnawaveURL string
|
||||
RemnawaveToken string
|
||||
RemnawaveCaddy string
|
||||
RemnawaveSubscription string
|
||||
RemnawavePanelURL string
|
||||
RemnawaveAPIToken string
|
||||
CaddyAuthAPIToken string
|
||||
RemnawaveSubscription string
|
||||
DatabaseURL string
|
||||
DefaultUserDays int
|
||||
TrialUserDays int
|
||||
DefaultExternalSquadUUID string
|
||||
DefaultInternalSquadUUIDs []string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@@ -31,15 +39,15 @@ func Load() (*Config, error) {
|
||||
|
||||
panelURL := strings.TrimRight(strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_URL")), "/")
|
||||
if panelURL == "" {
|
||||
return nil, fmt.Errorf("REMNAWAVE_PANEL_URL не задан")
|
||||
return nil, fmt.Errorf("REMNAWAVE_PANEL_URL не задан (URL панели, см. https://docs.rw/docs/install/subscription-page/bundled)")
|
||||
}
|
||||
if !strings.HasPrefix(panelURL, "http://") && !strings.HasPrefix(panelURL, "https://") {
|
||||
return nil, fmt.Errorf("REMNAWAVE_PANEL_URL должен начинаться с http:// или https://")
|
||||
return nil, fmt.Errorf("REMNAWAVE_PANEL_URL должен быть с http:// или https:// (как в документации Remnawave)")
|
||||
}
|
||||
|
||||
panelToken := strings.TrimSpace(os.Getenv("REMNAWAVE_API_TOKEN"))
|
||||
if panelToken == "" {
|
||||
return nil, fmt.Errorf("REMNAWAVE_API_TOKEN не задан (создайте в панели: Settings → API Tokens)")
|
||||
apiToken := strings.TrimSpace(os.Getenv("REMNAWAVE_API_TOKEN"))
|
||||
if apiToken == "" {
|
||||
return nil, fmt.Errorf("REMNAWAVE_API_TOKEN не задан (Remnawave Settings → API Tokens)")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_NAME"))
|
||||
@@ -47,19 +55,58 @@ func Load() (*Config, error) {
|
||||
name = "Панель 1"
|
||||
}
|
||||
|
||||
caddy := strings.TrimSpace(os.Getenv("CADDY_AUTH_API_TOKEN"))
|
||||
if caddy == "" {
|
||||
caddy = strings.TrimSpace(os.Getenv("REMNAWAVE_CADDY_TOKEN")) // устаревшее имя
|
||||
}
|
||||
|
||||
subURL := strings.TrimRight(strings.TrimSpace(os.Getenv("REMNAWAVE_SUBSCRIPTION_URL")), "/")
|
||||
if subURL != "" && !strings.HasPrefix(subURL, "http://") && !strings.HasPrefix(subURL, "https://") {
|
||||
return nil, fmt.Errorf("REMNAWAVE_SUBSCRIPTION_URL должен начинаться с http:// или https://")
|
||||
}
|
||||
|
||||
dbURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dbURL == "" {
|
||||
return nil, fmt.Errorf("DATABASE_URL не задан")
|
||||
}
|
||||
|
||||
days := 1
|
||||
if v := strings.TrimSpace(os.Getenv("DEFAULT_USER_DAYS")); v != "" {
|
||||
if d, err := strconv.Atoi(v); err == nil && d > 0 {
|
||||
days = d
|
||||
}
|
||||
}
|
||||
|
||||
trialDays := days
|
||||
if v := strings.TrimSpace(os.Getenv("TRIAL_USER_DAYS")); v != "" {
|
||||
if d, err := strconv.Atoi(v); err == nil && d > 0 {
|
||||
trialDays = d
|
||||
}
|
||||
}
|
||||
|
||||
var internalSquads []string
|
||||
if v := strings.TrimSpace(os.Getenv("DEFAULT_INTERNAL_SQUAD_UUIDS")); v != "" {
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
internalSquads = append(internalSquads, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Config{
|
||||
BotToken: token,
|
||||
BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"),
|
||||
TelegramAdminID: adminID,
|
||||
RemnawaveName: name,
|
||||
RemnawaveURL: panelURL,
|
||||
RemnawaveToken: panelToken,
|
||||
RemnawaveCaddy: strings.TrimSpace(os.Getenv("REMNAWAVE_CADDY_TOKEN")),
|
||||
RemnawaveSubscription: subURL,
|
||||
BotToken: token,
|
||||
BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"),
|
||||
TelegramAdminID: adminID,
|
||||
RemnawaveName: name,
|
||||
RemnawavePanelURL: panelURL,
|
||||
RemnawaveAPIToken: apiToken,
|
||||
CaddyAuthAPIToken: caddy,
|
||||
RemnawaveSubscription: subURL,
|
||||
DatabaseURL: dbURL,
|
||||
DefaultUserDays: days,
|
||||
TrialUserDays: trialDays,
|
||||
DefaultExternalSquadUUID: strings.TrimSpace(os.Getenv("DEFAULT_EXTERNAL_SQUAD_UUID")),
|
||||
DefaultInternalSquadUUIDs: internalSquads,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
type DB struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, databaseURL string) (*DB, error) {
|
||||
cfg, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse database url: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 10
|
||||
cfg.MinConns = 1
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect postgres: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
|
||||
d := &DB{pool: pool}
|
||||
if err := d.migrate(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *DB) Close() {
|
||||
d.pool.Close()
|
||||
}
|
||||
|
||||
func (d *DB) migrate(ctx context.Context) error {
|
||||
data, err := migrationsFS.ReadFile("migrations/001_init.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration: %w", err)
|
||||
}
|
||||
_, err = d.pool.Exec(ctx, string(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply migration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DB) Pool() *pgxpool.Pool {
|
||||
return d.pool
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS telegram_users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
telegram_id BIGINT NOT NULL UNIQUE,
|
||||
username TEXT,
|
||||
first_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vpn_users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
telegram_id BIGINT,
|
||||
remnawave_uuid UUID NOT NULL UNIQUE,
|
||||
remnawave_username VARCHAR(36) NOT NULL,
|
||||
external_squad_uuid UUID,
|
||||
internal_squad_uuids UUID[] NOT NULL DEFAULT '{}',
|
||||
expire_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vpn_users_telegram ON vpn_users(telegram_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vpn_users_username ON vpn_users(remnawave_username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_wizard (
|
||||
admin_telegram_id BIGINT PRIMARY KEY,
|
||||
step TEXT NOT NULL,
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,99 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type VPNUser struct {
|
||||
ID int64
|
||||
TelegramID *int64
|
||||
RemnawaveUUID string
|
||||
RemnawaveUsername string
|
||||
ExternalSquadUUID *string
|
||||
InternalSquadUUIDs []string
|
||||
ExpireAt *time.Time
|
||||
}
|
||||
|
||||
func (d *DB) UpsertTelegramUser(ctx context.Context, telegramID int64, username, firstName string) error {
|
||||
_, err := d.pool.Exec(ctx, `
|
||||
INSERT INTO telegram_users (telegram_id, username, first_name)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (telegram_id) DO UPDATE SET
|
||||
username = EXCLUDED.username,
|
||||
first_name = EXCLUDED.first_name`,
|
||||
telegramID, nullStr(username), nullStr(firstName))
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SaveVPNUser(ctx context.Context, u VPNUser) error {
|
||||
_, err := d.pool.Exec(ctx, `
|
||||
INSERT INTO vpn_users (
|
||||
telegram_id, remnawave_uuid, remnawave_username,
|
||||
external_squad_uuid, internal_squad_uuids, expire_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (remnawave_uuid) DO UPDATE SET
|
||||
telegram_id = EXCLUDED.telegram_id,
|
||||
remnawave_username = EXCLUDED.remnawave_username,
|
||||
external_squad_uuid = EXCLUDED.external_squad_uuid,
|
||||
internal_squad_uuids = EXCLUDED.internal_squad_uuids,
|
||||
expire_at = EXCLUDED.expire_at,
|
||||
updated_at = NOW()`,
|
||||
u.TelegramID, u.RemnawaveUUID, u.RemnawaveUsername,
|
||||
u.ExternalSquadUUID, u.InternalSquadUUIDs, u.ExpireAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) GetVPNByTelegramID(ctx context.Context, telegramID int64) (*VPNUser, error) {
|
||||
row := d.pool.QueryRow(ctx, `
|
||||
SELECT id, telegram_id, remnawave_uuid::text, remnawave_username,
|
||||
external_squad_uuid::text, internal_squad_uuids::text[], expire_at
|
||||
FROM vpn_users
|
||||
WHERE telegram_id = $1
|
||||
ORDER BY expire_at DESC NULLS LAST
|
||||
LIMIT 1`, telegramID)
|
||||
|
||||
var u VPNUser
|
||||
var ext *string
|
||||
var internal []string
|
||||
err := row.Scan(&u.ID, &u.TelegramID, &u.RemnawaveUUID, &u.RemnawaveUsername, &ext, &internal, &u.ExpireAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.ExternalSquadUUID = ext
|
||||
u.InternalSquadUUIDs = internal
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetVPNByUsername(ctx context.Context, username string) (*VPNUser, error) {
|
||||
row := d.pool.QueryRow(ctx, `
|
||||
SELECT id, telegram_id, remnawave_uuid::text, remnawave_username,
|
||||
external_squad_uuid::text, internal_squad_uuids::text[], expire_at
|
||||
FROM vpn_users WHERE remnawave_username = $1`, username)
|
||||
|
||||
var u VPNUser
|
||||
var ext *string
|
||||
var internal []string
|
||||
err := row.Scan(&u.ID, &u.TelegramID, &u.RemnawaveUUID, &u.RemnawaveUsername, &ext, &internal, &u.ExpireAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.ExternalSquadUUID = ext
|
||||
u.InternalSquadUUIDs = internal
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func nullStr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type WizardData map[string]any
|
||||
|
||||
type AdminWizard struct {
|
||||
AdminID int64
|
||||
Step string
|
||||
Data WizardData
|
||||
}
|
||||
|
||||
func (d *DB) GetWizard(ctx context.Context, adminID int64) (*AdminWizard, error) {
|
||||
row := d.pool.QueryRow(ctx, `
|
||||
SELECT admin_telegram_id, step, data
|
||||
FROM admin_wizard WHERE admin_telegram_id = $1`, adminID)
|
||||
|
||||
var w AdminWizard
|
||||
var raw []byte
|
||||
if err := row.Scan(&w.AdminID, &w.Step, &raw); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &w.Data)
|
||||
}
|
||||
if w.Data == nil {
|
||||
w.Data = WizardData{}
|
||||
}
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func (d *DB) SetWizard(ctx context.Context, adminID int64, step string, data WizardData) error {
|
||||
raw, _ := json.Marshal(data)
|
||||
_, err := d.pool.Exec(ctx, `
|
||||
INSERT INTO admin_wizard (admin_telegram_id, step, data, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (admin_telegram_id) DO UPDATE SET
|
||||
step = EXCLUDED.step,
|
||||
data = EXCLUDED.data,
|
||||
updated_at = NOW()`,
|
||||
adminID, step, raw)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) ClearWizard(ctx context.Context, adminID int64) error {
|
||||
_, err := d.pool.Exec(ctx, `DELETE FROM admin_wizard WHERE admin_telegram_id = $1`, adminID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w WizardData) String(key string) string {
|
||||
v, _ := w[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func (w WizardData) Int(key string) int {
|
||||
switch v := w[key].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (w WizardData) StringSlice(key string) []string {
|
||||
raw, ok := w[key].([]any)
|
||||
if !ok {
|
||||
if ss, ok := w[key].([]string); ok {
|
||||
return ss
|
||||
}
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, x := range raw {
|
||||
if s, ok := x.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (w WizardData) Set(key string, val any) {
|
||||
w[key] = val
|
||||
}
|
||||
|
||||
func (w WizardData) ToggleUUID(key, uuid string) {
|
||||
cur := w.StringSlice(key)
|
||||
for i, id := range cur {
|
||||
if id == uuid {
|
||||
cur = append(cur[:i], cur[i+1:]...)
|
||||
w[key] = cur
|
||||
return
|
||||
}
|
||||
}
|
||||
w[key] = append(cur, uuid)
|
||||
}
|
||||
|
||||
const (
|
||||
StepIdle = ""
|
||||
StepAwaitUsername = "await_username"
|
||||
StepAwaitDays = "await_days"
|
||||
StepPickExternalSquad = "pick_external"
|
||||
StepPickInternalSquads = "pick_internal"
|
||||
StepConfirm = "confirm"
|
||||
)
|
||||
|
||||
func DefaultExpireAt(days int) time.Time {
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
return time.Now().UTC().AddDate(0, 0, days)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package remnawave
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -10,24 +11,32 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client обращается к Remnawave Panel API:
|
||||
// - Base: REMNAWAVE_PANEL_URL (например https://panel.example.com)
|
||||
// - Auth: Authorization: Bearer REMNAWAVE_API_TOKEN
|
||||
// - Опционально: X-Api-Key: CADDY_AUTH_API_TOKEN
|
||||
// Документация: https://docs.rw/docs/install/subscription-page/bundled
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
panelURL string
|
||||
token string
|
||||
caddyToken string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, apiToken, caddyToken string) *Client {
|
||||
func NewClient(panelURL, apiToken, caddyAuthToken string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
panelURL: strings.TrimRight(panelURL, "/"),
|
||||
token: apiToken,
|
||||
caddyToken: caddyToken,
|
||||
caddyToken: caddyAuthToken,
|
||||
http: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) PanelURL() string { return c.panelURL }
|
||||
|
||||
func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) (int, error) {
|
||||
resp, body, err := c.get(ctx, path)
|
||||
if err != nil {
|
||||
@@ -40,13 +49,41 @@ func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) (
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string) (*http.Response, []byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
return c.doRequest(ctx, http.MethodGet, c.panelURL+path, nil, true)
|
||||
}
|
||||
|
||||
func (c *Client) getPublic(ctx context.Context, path string) (*http.Response, []byte, error) {
|
||||
return c.doRequest(ctx, http.MethodGet, c.panelURL+path, nil, false)
|
||||
}
|
||||
|
||||
func (c *Client) post(ctx context.Context, path string, body any) (*http.Response, []byte, error) {
|
||||
return c.doRequest(ctx, http.MethodPost, c.panelURL+path, body, true)
|
||||
}
|
||||
|
||||
func (c *Client) patch(ctx context.Context, path string, body any) (*http.Response, []byte, error) {
|
||||
return c.doRequest(ctx, http.MethodPatch, c.panelURL+path, body, true)
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(ctx context.Context, method, url string, body any, withBearer bool) (*http.Response, []byte, error) {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
bodyReader = bytes.NewReader(raw)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if withBearer {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if c.caddyToken != "" {
|
||||
req.Header.Set("X-Api-Key", c.caddyToken)
|
||||
}
|
||||
@@ -57,11 +94,19 @@ func (c *Client) get(ctx context.Context, path string) (*http.Response, []byte,
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return resp, nil, err
|
||||
}
|
||||
return resp, body, nil
|
||||
return resp, respBody, nil
|
||||
}
|
||||
|
||||
func apiError(status int, body []byte) error {
|
||||
msg := trimBody(body, 300)
|
||||
if msg == "" {
|
||||
return fmt.Errorf("HTTP %d", status)
|
||||
}
|
||||
return fmt.Errorf("HTTP %d: %s", status, msg)
|
||||
}
|
||||
|
||||
func parseCount(body []byte, arrayKey string) int {
|
||||
|
||||
+111
-45
@@ -8,41 +8,56 @@ import (
|
||||
)
|
||||
|
||||
type CheckItem struct {
|
||||
Name string
|
||||
OK bool
|
||||
Status int
|
||||
Detail string
|
||||
Name string
|
||||
OK bool
|
||||
Skipped bool
|
||||
Status int
|
||||
Detail string
|
||||
}
|
||||
|
||||
type HealthReport struct {
|
||||
PanelName string
|
||||
PanelURL string
|
||||
Checks []CheckItem
|
||||
Users int
|
||||
Nodes int
|
||||
AllOK bool
|
||||
PanelURL string
|
||||
Checks []CheckItem
|
||||
Users int
|
||||
Nodes int
|
||||
AllOK bool
|
||||
Hint string
|
||||
}
|
||||
|
||||
func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthReport {
|
||||
report := HealthReport{
|
||||
PanelName: "",
|
||||
PanelURL: c.baseURL,
|
||||
PanelURL: c.panelURL,
|
||||
}
|
||||
|
||||
probes := []struct {
|
||||
name string
|
||||
path string
|
||||
name string
|
||||
path string
|
||||
isWeb bool
|
||||
}{
|
||||
{"Панель (веб)", "/"},
|
||||
{"API (статистика)", "/api/system/stats/recap"},
|
||||
{"API (пользователи)", "/api/users"},
|
||||
{"API (ноды)", "/api/nodes"},
|
||||
{"Подписка (настройки)", "/api/subscription-settings"},
|
||||
{"Подписка (API список)", "/api/subscriptions"},
|
||||
{"Панель (веб)", "/", true},
|
||||
{"API (статистика)", "/api/system/stats/recap", false},
|
||||
{"API (пользователи)", "/api/users", false},
|
||||
{"API (ноды)", "/api/nodes", false},
|
||||
{"Подписка (настройки)", "/api/subscription-settings", false},
|
||||
{"Подписка (API список)", "/api/subscriptions", false},
|
||||
}
|
||||
|
||||
apiFailures := 0
|
||||
webOK := false
|
||||
|
||||
for _, p := range probes {
|
||||
item := c.probe(ctx, p.name, p.path)
|
||||
var item CheckItem
|
||||
if p.isWeb {
|
||||
item = c.probeWeb(ctx, p.name, p.path)
|
||||
if item.OK {
|
||||
webOK = true
|
||||
}
|
||||
} else {
|
||||
item = c.probeAPI(ctx, p.name, p.path)
|
||||
if !item.OK {
|
||||
apiFailures++
|
||||
}
|
||||
}
|
||||
report.Checks = append(report.Checks, item)
|
||||
|
||||
if p.path == "/api/users" && item.OK {
|
||||
@@ -62,16 +77,23 @@ func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthRe
|
||||
report.Checks = append(report.Checks, c.probePublic(ctx, "Страница подписки", subURL))
|
||||
} else {
|
||||
report.Checks = append(report.Checks, CheckItem{
|
||||
Name: "Страница подписки",
|
||||
OK: false,
|
||||
Status: 0,
|
||||
Detail: "не задана (REMNAWAVE_SUBSCRIPTION_URL)",
|
||||
Name: "Страница подписки",
|
||||
OK: true,
|
||||
Skipped: true,
|
||||
Detail: "опционально, не задана",
|
||||
})
|
||||
}
|
||||
|
||||
if webOK && apiFailures >= 4 {
|
||||
report.Hint = "Веб открывается, но /api/* недоступен (502). " +
|
||||
"По документации Remnawave API вызывается на REMNAWAVE_PANEL_URL (https://panel.example.com/api/...), " +
|
||||
"а не на домене sub.*. Укажите URL админ-панели и токен REMNAWAVE_API_TOKEN. " +
|
||||
"Док: https://docs.rw/docs/install/subscription-page/bundled"
|
||||
}
|
||||
|
||||
report.AllOK = true
|
||||
for _, ch := range report.Checks {
|
||||
if !ch.OK {
|
||||
if !ch.OK && !ch.Skipped {
|
||||
report.AllOK = false
|
||||
break
|
||||
}
|
||||
@@ -79,14 +101,27 @@ func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthRe
|
||||
return report
|
||||
}
|
||||
|
||||
func (c *Client) probe(ctx context.Context, name, path string) CheckItem {
|
||||
func (c *Client) probeWeb(ctx context.Context, name, path string) CheckItem {
|
||||
item := CheckItem{Name: name}
|
||||
resp, body, err := c.getPublic(ctx, path)
|
||||
if err != nil {
|
||||
item.Detail = err.Error()
|
||||
return item
|
||||
}
|
||||
return finishProbe(&item, resp, body)
|
||||
}
|
||||
|
||||
func (c *Client) probeAPI(ctx context.Context, name, path string) CheckItem {
|
||||
item := CheckItem{Name: name}
|
||||
resp, body, err := c.get(ctx, path)
|
||||
if err != nil {
|
||||
item.Detail = err.Error()
|
||||
return item
|
||||
}
|
||||
return finishProbe(&item, resp, body)
|
||||
}
|
||||
|
||||
func finishProbe(item *CheckItem, resp *http.Response, body []byte) CheckItem {
|
||||
item.Status = resp.StatusCode
|
||||
|
||||
switch resp.StatusCode {
|
||||
@@ -94,11 +129,31 @@ func (c *Client) probe(ctx context.Context, name, path string) CheckItem {
|
||||
item.OK = true
|
||||
item.Detail = "OK"
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
item.Detail = fmt.Sprintf("HTTP %d — неверный токен или нет прав", resp.StatusCode)
|
||||
item.Detail = "неверный REMNAWAVE_API_TOKEN или CADDY_AUTH_API_TOKEN"
|
||||
default:
|
||||
item.Detail = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, trimBody(body, 120))
|
||||
item.Detail = statusHint(resp.StatusCode)
|
||||
if item.Detail == "" {
|
||||
if s := trimBody(body, 80); s != "" {
|
||||
item.Detail = s
|
||||
} else {
|
||||
item.Detail = http.StatusText(resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
return *item
|
||||
}
|
||||
|
||||
func statusHint(code int) string {
|
||||
switch code {
|
||||
case http.StatusBadGateway:
|
||||
return "Bad Gateway — прокси не достучался до API панели"
|
||||
case http.StatusServiceUnavailable:
|
||||
return "Service Unavailable — бэкенд панели не запущен"
|
||||
case http.StatusNotFound:
|
||||
return "Not Found — путь /api не проксируется на панель"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func (c *Client) probePublic(ctx context.Context, name, url string) CheckItem {
|
||||
@@ -124,28 +179,35 @@ func (c *Client) probePublic(ctx context.Context, name, url string) CheckItem {
|
||||
item.Status = resp.StatusCode
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||||
item.OK = true
|
||||
item.Detail = fmt.Sprintf("OK (HTTP %d)", resp.StatusCode)
|
||||
item.Detail = "OK"
|
||||
} else {
|
||||
item.Detail = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
item.Detail = statusHint(resp.StatusCode)
|
||||
if item.Detail == "" {
|
||||
item.Detail = http.StatusText(resp.StatusCode)
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func FormatReport(r HealthReport, panelName, panelURL string) string {
|
||||
// FormatReport — обычный текст (без Markdown), чтобы URL и имена env отображались корректно.
|
||||
func FormatReport(r HealthReport, panelName string) string {
|
||||
var b strings.Builder
|
||||
if panelName != "" {
|
||||
b.WriteString(fmt.Sprintf("Панель: *%s*\nURL: `%s`\n\n", panelName, panelURL))
|
||||
}
|
||||
|
||||
icon := func(ok bool) string {
|
||||
if ok {
|
||||
return "✅"
|
||||
}
|
||||
return "❌"
|
||||
if panelName != "" {
|
||||
b.WriteString(fmt.Sprintf("Панель: %s\n", panelName))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("REMNAWAVE_PANEL_URL: %s\n", r.PanelURL))
|
||||
b.WriteString(fmt.Sprintf("API: %s/api/...\n\n", r.PanelURL))
|
||||
|
||||
for _, ch := range r.Checks {
|
||||
line := fmt.Sprintf("%s *%s*", icon(ch.OK), ch.Name)
|
||||
mark := "❌"
|
||||
switch {
|
||||
case ch.Skipped:
|
||||
mark = "○"
|
||||
case ch.OK:
|
||||
mark = "✅"
|
||||
}
|
||||
line := fmt.Sprintf("%s %s", mark, ch.Name)
|
||||
if ch.Status > 0 {
|
||||
line += fmt.Sprintf(" — HTTP %d", ch.Status)
|
||||
}
|
||||
@@ -156,13 +218,17 @@ func FormatReport(r HealthReport, panelName, panelURL string) string {
|
||||
}
|
||||
|
||||
if r.Users > 0 || r.Nodes > 0 {
|
||||
b.WriteString(fmt.Sprintf("\n👥 Пользователей: %d\n📡 Нод: %d", r.Users, r.Nodes))
|
||||
b.WriteString(fmt.Sprintf("\nПользователей: %d\nНод: %d", r.Users, r.Nodes))
|
||||
}
|
||||
|
||||
if r.Hint != "" {
|
||||
b.WriteString("\n\n💡 " + r.Hint)
|
||||
}
|
||||
|
||||
if r.AllOK {
|
||||
b.WriteString("\n\n✅ *Все проверки пройдены*")
|
||||
b.WriteString("\n\nВсе обязательные проверки пройдены.")
|
||||
} else {
|
||||
b.WriteString("\n\n⚠️ *Есть ошибки* — проверьте токен, URL и страницу подписки")
|
||||
b.WriteString("\n\nЕсть ошибки — см. подсказку выше.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package remnawave
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Squad struct {
|
||||
UUID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (c *Client) ListInternalSquads(ctx context.Context) ([]Squad, error) {
|
||||
return c.listSquads(ctx, "/api/internal-squads")
|
||||
}
|
||||
|
||||
func (c *Client) ListExternalSquads(ctx context.Context) ([]Squad, error) {
|
||||
return c.listSquads(ctx, "/api/external-squads")
|
||||
}
|
||||
|
||||
func (c *Client) listSquads(ctx context.Context, path string) ([]Squad, error) {
|
||||
resp, body, err := c.get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, apiError(resp.StatusCode, body)
|
||||
}
|
||||
return parseSquads(body), nil
|
||||
}
|
||||
|
||||
func parseSquads(body []byte) []Squad {
|
||||
var wrap map[string]json.RawMessage
|
||||
if json.Unmarshal(body, &wrap) != nil {
|
||||
return nil
|
||||
}
|
||||
if raw, ok := wrap["response"]; ok {
|
||||
if list := squadsFromRaw(raw); len(list) > 0 {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return squadsFromRaw(body)
|
||||
}
|
||||
|
||||
func squadsFromRaw(data []byte) []Squad {
|
||||
var arr []map[string]json.RawMessage
|
||||
if json.Unmarshal(data, &arr) == nil {
|
||||
return squadsFromMaps(arr)
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if json.Unmarshal(data, &obj) != nil {
|
||||
return nil
|
||||
}
|
||||
for _, key := range []string{"internalSquads", "externalSquads", "squads"} {
|
||||
if raw, ok := obj[key]; ok {
|
||||
if list := squadsFromRaw(raw); len(list) > 0 {
|
||||
return list
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, v := range obj {
|
||||
if list := squadsFromRaw(v); len(list) > 0 {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func squadsFromMaps(items []map[string]json.RawMessage) []Squad {
|
||||
out := make([]Squad, 0, len(items))
|
||||
for _, m := range items {
|
||||
s := Squad{}
|
||||
if raw, ok := m["uuid"]; ok {
|
||||
_ = json.Unmarshal(raw, &s.UUID)
|
||||
}
|
||||
if raw, ok := m["name"]; ok {
|
||||
_ = json.Unmarshal(raw, &s.Name)
|
||||
}
|
||||
if s.UUID != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package remnawave
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateUserInput struct {
|
||||
Username string
|
||||
ExpireAt time.Time
|
||||
TelegramID *int64
|
||||
ExternalSquadUUID *string
|
||||
ActiveInternalSquads []string
|
||||
TrafficLimitBytes *int64
|
||||
Description string
|
||||
}
|
||||
|
||||
type PanelUser struct {
|
||||
UUID string
|
||||
Username string
|
||||
ShortUUID string
|
||||
Status string
|
||||
ExpireAt time.Time
|
||||
SubscriptionURL string
|
||||
}
|
||||
|
||||
func (c *Client) GetUserByUsername(ctx context.Context, username string) (*PanelUser, error) {
|
||||
path := fmt.Sprintf("/api/users/by-username/%s", username)
|
||||
resp, body, err := c.get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, apiError(resp.StatusCode, body)
|
||||
}
|
||||
return parsePanelUser(body), nil
|
||||
}
|
||||
|
||||
func (c *Client) GetUserByTelegramID(ctx context.Context, telegramID int64) (*PanelUser, error) {
|
||||
path := fmt.Sprintf("/api/users/by-telegram-id/%d", telegramID)
|
||||
resp, body, err := c.get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, apiError(resp.StatusCode, body)
|
||||
}
|
||||
u := parsePanelUser(body)
|
||||
if u == nil || u.UUID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateUser(ctx context.Context, in CreateUserInput) (*PanelUser, error) {
|
||||
payload := map[string]any{
|
||||
"username": in.Username,
|
||||
"expireAt": in.ExpireAt.UTC().Format(time.RFC3339Nano),
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
if in.TelegramID != nil {
|
||||
payload["telegramId"] = *in.TelegramID
|
||||
}
|
||||
if in.ExternalSquadUUID != nil && *in.ExternalSquadUUID != "" {
|
||||
payload["externalSquadUuid"] = *in.ExternalSquadUUID
|
||||
}
|
||||
if len(in.ActiveInternalSquads) > 0 {
|
||||
payload["activeInternalSquads"] = in.ActiveInternalSquads
|
||||
}
|
||||
if in.TrafficLimitBytes != nil {
|
||||
payload["trafficLimitBytes"] = *in.TrafficLimitBytes
|
||||
}
|
||||
if in.Description != "" {
|
||||
payload["description"] = in.Description
|
||||
}
|
||||
|
||||
resp, body, err := c.post(ctx, "/api/users", payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return nil, apiError(resp.StatusCode, body)
|
||||
}
|
||||
return parsePanelUser(body), nil
|
||||
}
|
||||
|
||||
type AssignSquadsInput struct {
|
||||
UUID string
|
||||
Username string
|
||||
ExternalSquadUUID *string
|
||||
ActiveInternalSquads []string
|
||||
}
|
||||
|
||||
func (c *Client) AssignSquads(ctx context.Context, in AssignSquadsInput) (*PanelUser, error) {
|
||||
if in.UUID == "" && in.Username == "" {
|
||||
return nil, fmt.Errorf("нужен uuid или username")
|
||||
}
|
||||
payload := map[string]any{}
|
||||
if in.UUID != "" {
|
||||
payload["uuid"] = in.UUID
|
||||
} else {
|
||||
payload["username"] = in.Username
|
||||
}
|
||||
if in.ExternalSquadUUID != nil {
|
||||
payload["externalSquadUuid"] = in.ExternalSquadUUID
|
||||
}
|
||||
if in.ActiveInternalSquads != nil {
|
||||
payload["activeInternalSquads"] = in.ActiveInternalSquads
|
||||
}
|
||||
|
||||
resp, body, err := c.patch(ctx, "/api/users", payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, apiError(resp.StatusCode, body)
|
||||
}
|
||||
return parsePanelUser(body), nil
|
||||
}
|
||||
|
||||
func parsePanelUser(body []byte) *PanelUser {
|
||||
var wrap struct {
|
||||
Response map[string]json.RawMessage `json:"response"`
|
||||
}
|
||||
if json.Unmarshal(body, &wrap) != nil || wrap.Response == nil {
|
||||
return nil
|
||||
}
|
||||
u := &PanelUser{}
|
||||
if raw, ok := wrap.Response["uuid"]; ok {
|
||||
_ = json.Unmarshal(raw, &u.UUID)
|
||||
}
|
||||
if raw, ok := wrap.Response["username"]; ok {
|
||||
_ = json.Unmarshal(raw, &u.Username)
|
||||
}
|
||||
if raw, ok := wrap.Response["shortUuid"]; ok {
|
||||
_ = json.Unmarshal(raw, &u.ShortUUID)
|
||||
}
|
||||
if raw, ok := wrap.Response["status"]; ok {
|
||||
_ = json.Unmarshal(raw, &u.Status)
|
||||
}
|
||||
if raw, ok := wrap.Response["expireAt"]; ok {
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
u.ExpireAt = t
|
||||
} else if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
u.ExpireAt = t
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, ok := wrap.Response["subscriptionUrl"]; ok {
|
||||
_ = json.Unmarshal(raw, &u.SubscriptionURL)
|
||||
}
|
||||
return u
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"telegramvpn/internal/bot"
|
||||
"telegramvpn/internal/config"
|
||||
"telegramvpn/internal/db"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"github.com/joho/godotenv"
|
||||
@@ -18,22 +23,40 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
database, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
api, err := tgbotapi.NewBotAPI(cfg.BotToken)
|
||||
if err != nil {
|
||||
log.Fatalf("не удалось подключиться к Telegram: %v", err)
|
||||
}
|
||||
api.Debug = cfg.BotDebug
|
||||
|
||||
log.Printf("бот @%s запущен, админ ID %d, панель %q (%s)",
|
||||
api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawaveName, cfg.RemnawaveURL)
|
||||
log.Printf("бот @%s запущен, админ ID %d, панель %q, postgres ok",
|
||||
api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawavePanelURL)
|
||||
|
||||
handler := bot.NewHandler(cfg, api)
|
||||
handler := bot.NewHandler(cfg, api, database)
|
||||
handler.RegisterCommands()
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 60
|
||||
|
||||
for update := range api.GetUpdatesChan(u) {
|
||||
handler.HandleUpdate(update)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("остановка бота…")
|
||||
return
|
||||
case update, ok := <-api.GetUpdatesChan(u):
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
handler.HandleUpdate(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user