Add PostgreSQL, user/squad management, remove private domains from docs
This commit is contained in:
+13
-4
@@ -9,13 +9,22 @@ TELEGRAM_ADMIN_ID=123456789
|
|||||||
|
|
||||||
# --- Remnawave (официальные имена: https://docs.rw/docs/install/subscription-page/bundled) ---
|
# --- Remnawave (официальные имена: https://docs.rw/docs/install/subscription-page/bundled) ---
|
||||||
REMNAWAVE_PANEL_NAME=Панель 1
|
REMNAWAVE_PANEL_NAME=Панель 1
|
||||||
# URL панели: https://j5.evilfox.win (тест) или https://panel.example.com / http://remnawave:3000
|
# URL панели: https://panel.example.com или http://remnawave:3000 (внутри Docker-сети)
|
||||||
REMNAWAVE_PANEL_URL=https://j5.evilfox.win
|
REMNAWAVE_PANEL_URL=https://panel.example.com
|
||||||
# API-токен: Remnawave Settings → API Tokens (Authorization: Bearer)
|
# API-токен: Remnawave Settings → API Tokens (Authorization: Bearer)
|
||||||
REMNAWAVE_API_TOKEN=API_TOKEN_FROM_REMNAWAVE
|
REMNAWAVE_API_TOKEN=API_TOKEN_FROM_REMNAWAVE
|
||||||
# Если используется Caddy with security — X-Api-Key к панели
|
# Если используется Caddy with security — X-Api-Key к панели
|
||||||
CADDY_AUTH_API_TOKEN=
|
CADDY_AUTH_API_TOKEN=
|
||||||
# Опционально: Subscription Page (например sub5.evilfox.win)
|
# Опционально: Subscription Page (например https://sub.example.com)
|
||||||
REMNAWAVE_SUBSCRIPTION_URL=https://sub5.evilfox.win
|
REMNAWAVE_SUBSCRIPTION_URL=
|
||||||
|
|
||||||
|
# PostgreSQL (docker-compose подставляет URL к сервису db)
|
||||||
|
DATABASE_URL=postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable
|
||||||
|
|
||||||
|
# Создание пользователей по умолчанию
|
||||||
|
DEFAULT_USER_DAYS=30
|
||||||
|
# UUID сквадов из панели (/admin squads), через запятую для internal
|
||||||
|
DEFAULT_EXTERNAL_SQUAD_UUID=
|
||||||
|
DEFAULT_INTERNAL_SQUAD_UUIDS=
|
||||||
|
|
||||||
# Docker Compose: cp .env.example .env
|
# Docker Compose: cp .env.example .env
|
||||||
|
|||||||
+12
-2
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
|
||||||
|
- 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
|
## [0.20.0] — 2026-05-21
|
||||||
|
|
||||||
### Изменено
|
### Изменено
|
||||||
@@ -23,7 +33,7 @@
|
|||||||
- Раздел в README: Remnawave API (по официальной документации)
|
- Раздел в README: Remnawave API (по официальной документации)
|
||||||
- Пример `curl` для проверки API с сервера
|
- Пример `curl` для проверки API с сервера
|
||||||
|
|
||||||
[0.20.0]: https://git.evilfox.cc/test/tgvpn/releases/tag/v0.20.0
|
[0.20.0]: #
|
||||||
|
|
||||||
## [0.10.0-beta] — 2026-05-21
|
## [0.10.0-beta] — 2026-05-21
|
||||||
|
|
||||||
@@ -55,4 +65,4 @@
|
|||||||
- `internal/config` — загрузка конфигурации
|
- `internal/config` — загрузка конфигурации
|
||||||
- `internal/remnawave` — HTTP-клиент и health-check панели
|
- `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,6 +1,6 @@
|
|||||||
# tgvpn
|
# tgvpn
|
||||||
|
|
||||||
**Версия:** [0.20.0](CHANGELOG.md) · [Релизы](https://git.evilfox.cc/test/tgvpn/releases)
|
**Версия:** [0.20.0](CHANGELOG.md)
|
||||||
|
|
||||||
Telegram-бот на Go (базовое приветствие; далее — VPN-функции).
|
Telegram-бот на Go (базовое приветствие; далее — VPN-функции).
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ Telegram-бот на Go (базовое приветствие; далее — V
|
|||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| Docker | 24+ |
|
| Docker | 24+ |
|
||||||
| Docker Compose | v2 (`docker compose`) |
|
| Docker Compose | v2 (`docker compose`) |
|
||||||
|
| PostgreSQL | 16+ (в compose включён) |
|
||||||
| Токен бота | [@BotFather](https://t.me/BotFather) |
|
| Токен бота | [@BotFather](https://t.me/BotFather) |
|
||||||
| Сеть | Исходящий HTTPS к `api.telegram.org` (порт 443) |
|
| Сеть | Исходящий HTTPS к `api.telegram.org` (порт 443) |
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ Telegram-бот на Go (базовое приветствие; далее — V
|
|||||||
### 1. Клонирование
|
### 1. Клонирование
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.evilfox.cc/test/tgvpn.git
|
git clone <URL-вашего-репозитория>
|
||||||
cd tgvpn
|
cd tgvpn
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -39,9 +40,9 @@ BOT_TOKEN=ваш_токен_от_BotFather
|
|||||||
BOT_DEBUG=false
|
BOT_DEBUG=false
|
||||||
TELEGRAM_ADMIN_ID=123456789
|
TELEGRAM_ADMIN_ID=123456789
|
||||||
REMNAWAVE_PANEL_NAME=Панель 1
|
REMNAWAVE_PANEL_NAME=Панель 1
|
||||||
REMNAWAVE_PANEL_URL=https://j5.evilfox.win
|
REMNAWAVE_PANEL_URL=https://panel.example.com
|
||||||
REMNAWAVE_API_TOKEN=токен_из_панели
|
REMNAWAVE_API_TOKEN=токен_из_панели
|
||||||
REMNAWAVE_SUBSCRIPTION_URL=https://sub5.evilfox.win
|
REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Важно:** файл `.env` не попадает в git и не копируется в образ. Compose передаёт переменные в контейнер при старте.
|
> **Важно:** файл `.env` не попадает в git и не копируется в образ. Compose передаёт переменные в контейнер при старте.
|
||||||
@@ -115,7 +116,7 @@ docker compose version
|
|||||||
sudo mkdir -p /opt/tgvpn
|
sudo mkdir -p /opt/tgvpn
|
||||||
sudo chown $USER:$USER /opt/tgvpn
|
sudo chown $USER:$USER /opt/tgvpn
|
||||||
cd /opt/tgvpn
|
cd /opt/tgvpn
|
||||||
git clone https://git.evilfox.cc/test/tgvpn.git .
|
git clone <URL-вашего-репозитория> .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Шаг 4. Настройка `.env`
|
### Шаг 4. Настройка `.env`
|
||||||
@@ -260,7 +261,7 @@ docker image prune -f
|
|||||||
| `git pull` конфликтует с локальными правками | `git stash` → `git pull` → `git stash pop` или сбросить локальные изменения: `git checkout -- .` |
|
| `git pull` конфликтует с локальными правками | `git stash` → `git pull` → `git stash pop` или сбросить локальные изменения: `git checkout -- .` |
|
||||||
| Бот не стартует после pull | `docker compose logs bot` — часто не хватает новой переменной в `.env` |
|
| Бот не стартует после pull | `docker compose logs bot` — часто не хватает новой переменной в `.env` |
|
||||||
| Старый код в контейнере | Обязательно `--build`: `docker compose up -d --build` |
|
| Старый код в контейнере | Обязательно `--build`: `docker compose up -d --build` |
|
||||||
| Нет доступа к git | Проверьте SSH/HTTPS-доступ к `git.evilfox.cc` |
|
| Нет доступа к git | Проверьте SSH/HTTPS-доступ к вашему git-серверу |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -272,7 +273,7 @@ docker image prune -f
|
|||||||
2. В PowerShell:
|
2. В PowerShell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git clone https://git.evilfox.cc/test/tgvpn.git
|
git clone <URL-вашего-репозитория>
|
||||||
cd tgvpn
|
cd tgvpn
|
||||||
Copy-Item .env.example .env
|
Copy-Item .env.example .env
|
||||||
# отредактируйте .env — вставьте BOT_TOKEN
|
# отредактируйте .env — вставьте BOT_TOKEN
|
||||||
@@ -310,6 +311,10 @@ go build -o bot .
|
|||||||
| `REMNAWAVE_API_TOKEN` | да | Токен из **Remnawave Settings → API Tokens**, заголовок `Authorization: Bearer` |
|
| `REMNAWAVE_API_TOKEN` | да | Токен из **Remnawave Settings → API Tokens**, заголовок `Authorization: Bearer` |
|
||||||
| `CADDY_AUTH_API_TOKEN` | нет | `X-Api-Key`, если включён Caddy with security (как в оф. `.env` subscription-page) |
|
| `CADDY_AUTH_API_TOKEN` | нет | `X-Api-Key`, если включён Caddy with security (как в оф. `.env` subscription-page) |
|
||||||
| `REMNAWAVE_SUBSCRIPTION_URL` | нет | Опционально: домен Subscription Page (`sub.*`), отдельная проверка |
|
| `REMNAWAVE_SUBSCRIPTION_URL` | нет | Опционально: домен Subscription Page (`sub.*`), отдельная проверка |
|
||||||
|
| `DATABASE_URL` | да | PostgreSQL, в compose: `postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable` |
|
||||||
|
| `DEFAULT_USER_DAYS` | нет | Срок подписки по умолчанию (30) |
|
||||||
|
| `DEFAULT_EXTERNAL_SQUAD_UUID` | нет | External squad по умолчанию при быстром создании |
|
||||||
|
| `DEFAULT_INTERNAL_SQUAD_UUIDS` | нет | Internal squads через запятую |
|
||||||
| `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) |
|
| `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) |
|
||||||
|
|
||||||
### Админ-меню в боте
|
### Админ-меню в боте
|
||||||
@@ -319,7 +324,11 @@ go build -o bot .
|
|||||||
- `/admin` — админ-меню (панель 1, Remnawave)
|
- `/admin` — админ-меню (панель 1, Remnawave)
|
||||||
- `/admin check` — полная проверка: веб панели, API (статистика, users, nodes), подписка (settings + API), страница подписки
|
- `/admin check` — полная проверка: веб панели, API (статистика, users, nodes), подписка (settings + API), страница подписки
|
||||||
- `/admin config` — конфиг панели в боте
|
- `/admin config` — конфиг панели в боте
|
||||||
- Кнопки снизу (после `/start`): «Проверить панель», «Конфиг панели»
|
- `/admin user` — мастер создания пользователя в Remnawave + назначение сквадов
|
||||||
|
- `/admin user <логин> [дней]` — быстрое создание (сквады из `DEFAULT_*` в `.env`)
|
||||||
|
- `/admin squads` — список internal/external squads
|
||||||
|
- `/admin assign <логин>` — назначить сквады существующему пользователю
|
||||||
|
- Кнопки: «Создать пользователя», «Сквады», «Проверить панель», «Конфиг»
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -410,9 +419,9 @@ docker compose logs bot # ошибки сети, токена
|
|||||||
|
|
||||||
Частая причина: в `REMNAWAVE_PANEL_URL` указан домен **страницы подписки** (`sub.example.com`), а не **админ-панели** (`panel.example.com`).
|
Частая причина: в `REMNAWAVE_PANEL_URL` указан домен **страницы подписки** (`sub.example.com`), а не **админ-панели** (`panel.example.com`).
|
||||||
|
|
||||||
1. Укажите URL **панели** (не sub): `REMNAWAVE_PANEL_URL=https://j5.evilfox.win`
|
1. Укажите URL **панели** (не sub): `REMNAWAVE_PANEL_URL=https://panel.example.com`
|
||||||
2. Токен API: `REMNAWAVE_API_TOKEN=...` (Settings → API Tokens)
|
2. Токен API: `REMNAWAVE_API_TOKEN=...` (Settings → API Tokens)
|
||||||
3. Страницу подписки — опционально: `REMNAWAVE_SUBSCRIPTION_URL=https://sub5.evilfox.win`
|
3. Страницу подписки — опционально: `REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com`
|
||||||
4. Проверьте на сервере: `docker compose ps` (Remnawave Panel запущен), логи reverse proxy
|
4. Проверьте на сервере: `docker compose ps` (Remnawave Panel запущен), логи reverse proxy
|
||||||
|
|
||||||
### Контейнер постоянно перезапускается
|
### Контейнер постоянно перезапускается
|
||||||
@@ -438,9 +447,10 @@ sudo usermod -aG docker $USER
|
|||||||
tgvpn/
|
tgvpn/
|
||||||
├── main.go
|
├── main.go
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── bot/ # обработчики Telegram, админ-меню
|
│ ├── bot/ # Telegram, админ-меню, создание пользователей
|
||||||
│ ├── config/ # переменные окружения
|
│ ├── config/ # переменные окружения
|
||||||
│ └── remnawave/ # клиент API панели
|
│ ├── db/ # PostgreSQL, миграции, мастер админа
|
||||||
|
│ └── remnawave/ # API панели (users, squads)
|
||||||
├── Dockerfile # multi-stage сборка
|
├── Dockerfile # multi-stage сборка
|
||||||
├── docker-compose.yml # оркестрация
|
├── docker-compose.yml # оркестрация
|
||||||
├── .env.example # шаблон переменных
|
├── .env.example # шаблон переменных
|
||||||
@@ -454,4 +464,4 @@ tgvpn/
|
|||||||
|
|
||||||
## Репозиторий
|
## Репозиторий
|
||||||
|
|
||||||
https://git.evilfox.cc/test/tgvpn.git
|
Укажите URL вашего приватного git-репозитория при клонировании.
|
||||||
|
|||||||
+23
-2
@@ -1,4 +1,20 @@
|
|||||||
services:
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: tgvpn-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: tgvpn
|
||||||
|
POSTGRES_PASSWORD: tgvpn
|
||||||
|
POSTGRES_DB: tgvpn
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U tgvpn -d tgvpn"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -6,9 +22,14 @@ services:
|
|||||||
image: tgvpn-bot:latest
|
image: tgvpn-bot:latest
|
||||||
container_name: tgvpn-bot
|
container_name: tgvpn-bot
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
BOT_DEBUG: ${BOT_DEBUG:-false}
|
BOT_DEBUG: ${BOT_DEBUG:-false}
|
||||||
# Long polling — исходящие HTTPS к api.telegram.org
|
DATABASE_URL: postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable
|
||||||
# ports не нужны, пока нет webhook
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
module telegramvpn
|
module telegramvpn
|
||||||
|
|
||||||
go 1.22
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
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
|
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 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+43
-7
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"telegramvpn/internal/config"
|
"telegramvpn/internal/config"
|
||||||
|
"telegramvpn/internal/db"
|
||||||
"telegramvpn/internal/remnawave"
|
"telegramvpn/internal/remnawave"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
@@ -19,14 +20,16 @@ type Handler struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
api *tgbotapi.BotAPI
|
api *tgbotapi.BotAPI
|
||||||
panel *remnawave.Client
|
panel *remnawave.Client
|
||||||
|
database *db.DB
|
||||||
admin int64
|
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{
|
return &Handler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
api: api,
|
api: api,
|
||||||
panel: remnawave.NewClient(cfg.RemnawavePanelURL, cfg.RemnawaveAPIToken, cfg.CaddyAuthAPIToken),
|
panel: remnawave.NewClient(cfg.RemnawavePanelURL, cfg.RemnawaveAPIToken, cfg.CaddyAuthAPIToken),
|
||||||
|
database: database,
|
||||||
admin: cfg.TelegramAdminID,
|
admin: cfg.TelegramAdminID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,12 +64,15 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case text == "/start":
|
case text == "/start":
|
||||||
h.sendStart(chatID, userID, update.Message.From.FirstName)
|
h.sendStart(chatID, userID, update.Message.From.FirstName, update.Message.From.UserName)
|
||||||
case strings.HasPrefix(text, "/admin"):
|
case strings.HasPrefix(text, "/admin"):
|
||||||
h.handleAdminCommand(chatID, userID, text)
|
h.handleAdminCommand(chatID, userID, text)
|
||||||
case strings.HasPrefix(text, "/"):
|
case strings.HasPrefix(text, "/"):
|
||||||
h.sendText(chatID, "Неизвестная команда. Для начала — /start")
|
h.sendText(chatID, "Неизвестная команда. Для начала — /start")
|
||||||
default:
|
default:
|
||||||
|
if h.isAdmin(userID) && h.handleWizardMessage(chatID, userID, text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if h.isAdmin(userID) {
|
if h.isAdmin(userID) {
|
||||||
switch text {
|
switch text {
|
||||||
case "📋 Конфиг панели":
|
case "📋 Конфиг панели":
|
||||||
@@ -75,6 +81,12 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) {
|
|||||||
case "🔌 Проверить панель":
|
case "🔌 Проверить панель":
|
||||||
h.sendPanelCheck(chatID)
|
h.sendPanelCheck(chatID)
|
||||||
return
|
return
|
||||||
|
case "👤 Создать пользователя":
|
||||||
|
h.startUserWizard(chatID, userID)
|
||||||
|
return
|
||||||
|
case "📡 Сквады":
|
||||||
|
h.sendSquadsList(chatID)
|
||||||
|
return
|
||||||
case "◀️ Выйти из админки":
|
case "◀️ Выйти из админки":
|
||||||
h.sendText(chatID, "Админ-меню закрыто. /admin — снова открыть.")
|
h.sendText(chatID, "Админ-меню закрыто. /admin — снова открыть.")
|
||||||
return
|
return
|
||||||
@@ -101,6 +113,8 @@ func (h *Handler) handleAdminCommand(chatID, userID int64, text string) {
|
|||||||
h.sendPanelCheck(chatID)
|
h.sendPanelCheck(chatID)
|
||||||
case "config", "конфиг":
|
case "config", "конфиг":
|
||||||
h.sendPanelConfig(chatID)
|
h.sendPanelConfig(chatID)
|
||||||
|
case "user", "пользователь", "squads", "сквады", "assign", "сквад", "cancel", "отмена", "help":
|
||||||
|
h.handleAdminUsersSubcommand(chatID, userID, args[2:])
|
||||||
default:
|
default:
|
||||||
h.sendAdminHelp(chatID)
|
h.sendAdminHelp(chatID)
|
||||||
}
|
}
|
||||||
@@ -117,7 +131,15 @@ func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.handleWizardCallback(cq) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch cq.Data {
|
switch cq.Data {
|
||||||
|
case "admin:user":
|
||||||
|
h.startUserWizard(cq.Message.Chat.ID, cq.From.ID)
|
||||||
|
case "admin:squads":
|
||||||
|
h.sendSquadsList(cq.Message.Chat.ID)
|
||||||
case "admin:config":
|
case "admin:config":
|
||||||
h.sendPanelConfig(cq.Message.Chat.ID)
|
h.sendPanelConfig(cq.Message.Chat.ID)
|
||||||
case "admin:check":
|
case "admin:check":
|
||||||
@@ -133,14 +155,17 @@ func (h *Handler) isAdmin(userID int64) bool {
|
|||||||
return userID == h.admin
|
return userID == h.admin
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendStart(chatID, userID int64, firstName string) {
|
func (h *Handler) sendStart(chatID, userID int64, firstName, tgUsername string) {
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = h.database.UpsertTelegramUser(ctx, userID, tgUsername, firstName)
|
||||||
|
|
||||||
name := firstName
|
name := firstName
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = "друг"
|
name = "друг"
|
||||||
}
|
}
|
||||||
text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот на базе панели Remnawave.", name)
|
text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот на базе панели Remnawave.", name)
|
||||||
if h.isAdmin(userID) {
|
if h.isAdmin(userID) {
|
||||||
text += "\n\n/admin — админ-меню\n/admin check — проверка API и подписки"
|
text += "\n\n/admin — админ-меню\n/admin user — создать пользователя\n/admin squads — сквады"
|
||||||
}
|
}
|
||||||
msg := tgbotapi.NewMessage(chatID, text)
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
if h.isAdmin(userID) {
|
if h.isAdmin(userID) {
|
||||||
@@ -155,7 +180,10 @@ func (h *Handler) sendAdminMenu(chatID int64) {
|
|||||||
"Команды:\n"+
|
"Команды:\n"+
|
||||||
"• /admin — это меню\n"+
|
"• /admin — это меню\n"+
|
||||||
"• /admin check — проверка панели, API и подписки\n"+
|
"• /admin check — проверка панели, API и подписки\n"+
|
||||||
"• /admin config — конфиг панели\n\n"+
|
"• /admin config — конфиг панели\n"+
|
||||||
|
"• /admin user — создать пользователя\n"+
|
||||||
|
"• /admin squads — список сквадов\n"+
|
||||||
|
"• /admin assign <логин> — назначить сквады\n\n"+
|
||||||
"Или кнопки ниже.",
|
"Или кнопки ниже.",
|
||||||
escapeMarkdown(h.cfg.RemnawaveName),
|
escapeMarkdown(h.cfg.RemnawaveName),
|
||||||
)
|
)
|
||||||
@@ -166,7 +194,7 @@ func (h *Handler) sendAdminMenu(chatID int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendAdminHelp(chatID int64) {
|
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) {
|
func (h *Handler) sendPanelConfig(chatID int64) {
|
||||||
@@ -219,7 +247,11 @@ func (h *Handler) sendPanelCheck(chatID int64) {
|
|||||||
func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
|
func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
|
||||||
return tgbotapi.NewInlineKeyboardMarkup(
|
return tgbotapi.NewInlineKeyboardMarkup(
|
||||||
tgbotapi.NewInlineKeyboardRow(
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить (API+подписка)", "admin:check"),
|
tgbotapi.NewInlineKeyboardButtonData("👤 Создать пользователя", "admin:user"),
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("📡 Сквады", "admin:squads"),
|
||||||
|
),
|
||||||
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить", "admin:check"),
|
||||||
tgbotapi.NewInlineKeyboardButtonData("📋 Конфиг", "admin:config"),
|
tgbotapi.NewInlineKeyboardButtonData("📋 Конфиг", "admin:config"),
|
||||||
),
|
),
|
||||||
tgbotapi.NewInlineKeyboardRow(
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
@@ -231,6 +263,10 @@ func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
|
|||||||
func adminReplyKeyboard() tgbotapi.ReplyKeyboardMarkup {
|
func adminReplyKeyboard() tgbotapi.ReplyKeyboardMarkup {
|
||||||
return tgbotapi.ReplyKeyboardMarkup{
|
return tgbotapi.ReplyKeyboardMarkup{
|
||||||
Keyboard: [][]tgbotapi.KeyboardButton{
|
Keyboard: [][]tgbotapi.KeyboardButton{
|
||||||
|
{
|
||||||
|
tgbotapi.NewKeyboardButton("👤 Создать пользователя"),
|
||||||
|
tgbotapi.NewKeyboardButton("📡 Сквады"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tgbotapi.NewKeyboardButton("🔌 Проверить панель"),
|
tgbotapi.NewKeyboardButton("🔌 Проверить панель"),
|
||||||
tgbotapi.NewKeyboardButton("📋 Конфиг панели"),
|
tgbotapi.NewKeyboardButton("📋 Конфиг панели"),
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ type Config struct {
|
|||||||
RemnawaveAPIToken string
|
RemnawaveAPIToken string
|
||||||
CaddyAuthAPIToken string
|
CaddyAuthAPIToken string
|
||||||
RemnawaveSubscription string
|
RemnawaveSubscription string
|
||||||
|
DatabaseURL string
|
||||||
|
DefaultUserDays int
|
||||||
|
DefaultExternalSquadUUID string
|
||||||
|
DefaultInternalSquadUUIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -60,6 +64,28 @@ func Load() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("REMNAWAVE_SUBSCRIPTION_URL должен начинаться с http:// или 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 := 30
|
||||||
|
if v := strings.TrimSpace(os.Getenv("DEFAULT_USER_DAYS")); v != "" {
|
||||||
|
if d, err := strconv.Atoi(v); err == nil && d > 0 {
|
||||||
|
days = 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{
|
return &Config{
|
||||||
BotToken: token,
|
BotToken: token,
|
||||||
BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"),
|
BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"),
|
||||||
@@ -69,5 +95,9 @@ func Load() (*Config, error) {
|
|||||||
RemnawaveAPIToken: apiToken,
|
RemnawaveAPIToken: apiToken,
|
||||||
CaddyAuthAPIToken: caddy,
|
CaddyAuthAPIToken: caddy,
|
||||||
RemnawaveSubscription: subURL,
|
RemnawaveSubscription: subURL,
|
||||||
|
DatabaseURL: dbURL,
|
||||||
|
DefaultUserDays: days,
|
||||||
|
DefaultExternalSquadUUID: strings.TrimSpace(os.Getenv("DEFAULT_EXTERNAL_SQUAD_UUID")),
|
||||||
|
DefaultInternalSquadUUIDs: internalSquads,
|
||||||
}, nil
|
}, 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,75 @@
|
|||||||
|
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) 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
|
package remnawave
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -48,20 +49,38 @@ func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) get(ctx context.Context, path string) (*http.Response, []byte, error) {
|
func (c *Client) get(ctx context.Context, path string) (*http.Response, []byte, error) {
|
||||||
return c.doRequest(ctx, c.panelURL+path, true)
|
return c.doRequest(ctx, http.MethodGet, c.panelURL+path, nil, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) getPublic(ctx context.Context, path string) (*http.Response, []byte, error) {
|
func (c *Client) getPublic(ctx context.Context, path string) (*http.Response, []byte, error) {
|
||||||
return c.doRequest(ctx, c.panelURL+path, false)
|
return c.doRequest(ctx, http.MethodGet, c.panelURL+path, nil, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) doRequest(ctx context.Context, url string, withBearer bool) (*http.Response, []byte, error) {
|
func (c *Client) post(ctx context.Context, path string, body any) (*http.Response, []byte, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/json, text/html, */*")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
if withBearer {
|
if withBearer {
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
}
|
}
|
||||||
@@ -75,11 +94,19 @@ func (c *Client) doRequest(ctx context.Context, url string, withBearer bool) (*h
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 {
|
if err != nil {
|
||||||
return resp, nil, err
|
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 {
|
func parseCount(body []byte, arrayKey string) int {
|
||||||
|
|||||||
@@ -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,128 @@
|
|||||||
|
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) 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, ok := wrap.Response["subscriptionUrl"]; ok {
|
||||||
|
_ = json.Unmarshal(raw, &u.SubscriptionURL)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"telegramvpn/internal/bot"
|
"telegramvpn/internal/bot"
|
||||||
"telegramvpn/internal/config"
|
"telegramvpn/internal/config"
|
||||||
|
"telegramvpn/internal/db"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -18,22 +23,40 @@ func main() {
|
|||||||
log.Fatal(err)
|
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)
|
api, err := tgbotapi.NewBotAPI(cfg.BotToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("не удалось подключиться к Telegram: %v", err)
|
log.Fatalf("не удалось подключиться к Telegram: %v", err)
|
||||||
}
|
}
|
||||||
api.Debug = cfg.BotDebug
|
api.Debug = cfg.BotDebug
|
||||||
|
|
||||||
log.Printf("бот @%s запущен, админ ID %d, панель %q (%s)",
|
log.Printf("бот @%s запущен, админ ID %d, панель %q, postgres ok",
|
||||||
api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawaveName, cfg.RemnawavePanelURL)
|
api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawavePanelURL)
|
||||||
|
|
||||||
handler := bot.NewHandler(cfg, api)
|
handler := bot.NewHandler(cfg, api, database)
|
||||||
handler.RegisterCommands()
|
handler.RegisterCommands()
|
||||||
|
|
||||||
u := tgbotapi.NewUpdate(0)
|
u := tgbotapi.NewUpdate(0)
|
||||||
u.Timeout = 60
|
u.Timeout = 60
|
||||||
|
|
||||||
for update := range api.GetUpdatesChan(u) {
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Println("остановка бота…")
|
||||||
|
return
|
||||||
|
case update, ok := <-api.GetUpdatesChan(u):
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
handler.HandleUpdate(update)
|
handler.HandleUpdate(update)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user