Add PostgreSQL, user/squad management, remove private domains from docs

This commit is contained in:
tgvpn
2026-05-21 01:13:23 +03:00
parent d0dc8d5822
commit 5e3229e998
17 changed files with 1171 additions and 58 deletions
+13 -4
View File
@@ -9,13 +9,22 @@ TELEGRAM_ADMIN_ID=123456789
# --- Remnawave (официальные имена: https://docs.rw/docs/install/subscription-page/bundled) ---
REMNAWAVE_PANEL_NAME=Панель 1
# URL панели: https://j5.evilfox.win (тест) или https://panel.example.com / http://remnawave:3000
REMNAWAVE_PANEL_URL=https://j5.evilfox.win
# URL панели: https://panel.example.com или http://remnawave:3000 (внутри Docker-сети)
REMNAWAVE_PANEL_URL=https://panel.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 (например sub5.evilfox.win)
REMNAWAVE_SUBSCRIPTION_URL=https://sub5.evilfox.win
# Опционально: Subscription Page (например https://sub.example.com)
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
+12 -2
View File
@@ -2,6 +2,16 @@
Формат основан на [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
### Изменено
@@ -23,7 +33,7 @@
- Раздел в README: Remnawave 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
@@ -55,4 +65,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]: #
+23 -13
View File
@@ -1,6 +1,6 @@
# tgvpn
**Версия:** [0.20.0](CHANGELOG.md) · [Релизы](https://git.evilfox.cc/test/tgvpn/releases)
**Версия:** [0.20.0](CHANGELOG.md)
Telegram-бот на Go (базовое приветствие; далее — VPN-функции).
@@ -10,6 +10,7 @@ Telegram-бот на Go (базовое приветствие; далее — V
|-----------|---------|
| Docker | 24+ |
| Docker Compose | v2 (`docker compose`) |
| PostgreSQL | 16+ (в compose включён) |
| Токен бота | [@BotFather](https://t.me/BotFather) |
| Сеть | Исходящий HTTPS к `api.telegram.org` (порт 443) |
@@ -22,7 +23,7 @@ Telegram-бот на Go (базовое приветствие; далее — V
### 1. Клонирование
```bash
git clone https://git.evilfox.cc/test/tgvpn.git
git clone <URL-вашего-репозитория>
cd tgvpn
```
@@ -39,9 +40,9 @@ BOT_TOKEN=ваш_токен_от_BotFather
BOT_DEBUG=false
TELEGRAM_ADMIN_ID=123456789
REMNAWAVE_PANEL_NAME=Панель 1
REMNAWAVE_PANEL_URL=https://j5.evilfox.win
REMNAWAVE_PANEL_URL=https://panel.example.com
REMNAWAVE_API_TOKEN=токен_из_панели
REMNAWAVE_SUBSCRIPTION_URL=https://sub5.evilfox.win
REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com
```
> **Важно:** файл `.env` не попадает в git и не копируется в образ. Compose передаёт переменные в контейнер при старте.
@@ -115,7 +116,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`
@@ -260,7 +261,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 +273,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
@@ -310,6 +311,10 @@ go build -o bot .
| `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` |
| `DEFAULT_USER_DAYS` | нет | Срок подписки по умолчанию (30) |
| `DEFAULT_EXTERNAL_SQUAD_UUID` | нет | External squad по умолчанию при быстром создании |
| `DEFAULT_INTERNAL_SQUAD_UUIDS` | нет | Internal squads через запятую |
| `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) |
### Админ-меню в боте
@@ -319,7 +324,11 @@ 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 <логин>` — назначить сквады существующему пользователю
- Кнопки: «Создать пользователя», «Сквады», «Проверить панель», «Конфиг»
---
@@ -410,9 +419,9 @@ docker compose logs bot # ошибки сети, токена
Частая причина: в `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)
3. Страницу подписки — опционально: `REMNAWAVE_SUBSCRIPTION_URL=https://sub5.evilfox.win`
3. Страницу подписки — опционально: `REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com`
4. Проверьте на сервере: `docker compose ps` (Remnawave Panel запущен), логи reverse proxy
### Контейнер постоянно перезапускается
@@ -438,9 +447,10 @@ sudo usermod -aG docker $USER
tgvpn/
├── main.go
├── internal/
│ ├── bot/ # обработчики Telegram, админ-меню
│ ├── bot/ # Telegram, админ-меню, создание пользователей
│ ├── config/ # переменные окружения
── remnawave/ # клиент API панели
── db/ # PostgreSQL, миграции, мастер админа
│ └── remnawave/ # API панели (users, squads)
├── Dockerfile # multi-stage сборка
├── docker-compose.yml # оркестрация
├── .env.example # шаблон переменных
@@ -454,4 +464,4 @@ tgvpn/
## Репозиторий
https://git.evilfox.cc/test/tgvpn.git
Укажите URL вашего приватного git-репозитория при клонировании.
+23 -2
View File
@@ -1,4 +1,20 @@
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:
build:
context: .
@@ -6,9 +22,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: postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable
volumes:
pgdata:
+10 -1
View File
@@ -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
)
+26
View File
@@ -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=
+407
View File
@@ -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, _, - (336 символов).")
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
View File
@@ -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"
@@ -19,14 +20,16 @@ type Handler struct {
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.RemnawavePanelURL, cfg.RemnawaveAPIToken, cfg.CaddyAuthAPIToken),
database: database,
admin: cfg.TelegramAdminID,
}
}
@@ -61,12 +64,15 @@ 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 strings.HasPrefix(text, "/admin"):
h.handleAdminCommand(chatID, userID, text)
case strings.HasPrefix(text, "/"):
h.sendText(chatID, "Неизвестная команда. Для начала — /start")
default:
if h.isAdmin(userID) && h.handleWizardMessage(chatID, userID, text) {
return
}
if h.isAdmin(userID) {
switch text {
case "📋 Конфиг панели":
@@ -75,6 +81,12 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) {
case "🔌 Проверить панель":
h.sendPanelCheck(chatID)
return
case "👤 Создать пользователя":
h.startUserWizard(chatID, userID)
return
case "📡 Сквады":
h.sendSquadsList(chatID)
return
case "◀️ Выйти из админки":
h.sendText(chatID, "Админ-меню закрыто. /admin — снова открыть.")
return
@@ -101,6 +113,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)
}
@@ -117,7 +131,15 @@ func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) {
return
}
if h.handleWizardCallback(cq) {
return
}
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":
h.sendPanelConfig(cq.Message.Chat.ID)
case "admin:check":
@@ -133,14 +155,17 @@ 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) {
ctx := context.Background()
_ = h.database.UpsertTelegramUser(ctx, userID, tgUsername, firstName)
name := firstName
if name == "" {
name = "друг"
}
text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот на базе панели Remnawave.", name)
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)
if h.isAdmin(userID) {
@@ -155,7 +180,10 @@ func (h *Handler) sendAdminMenu(chatID int64) {
"Команды:\n"+
"• /admin — это меню\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),
)
@@ -166,7 +194,7 @@ func (h *Handler) sendAdminMenu(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) {
@@ -219,7 +247,11 @@ func (h *Handler) sendPanelCheck(chatID int64) {
func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
return tgbotapi.NewInlineKeyboardMarkup(
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.NewInlineKeyboardRow(
@@ -231,6 +263,10 @@ func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
func adminReplyKeyboard() tgbotapi.ReplyKeyboardMarkup {
return tgbotapi.ReplyKeyboardMarkup{
Keyboard: [][]tgbotapi.KeyboardButton{
{
tgbotapi.NewKeyboardButton("👤 Создать пользователя"),
tgbotapi.NewKeyboardButton("📡 Сквады"),
},
{
tgbotapi.NewKeyboardButton("🔌 Проверить панель"),
tgbotapi.NewKeyboardButton("📋 Конфиг панели"),
+30
View File
@@ -19,6 +19,10 @@ type Config struct {
RemnawaveAPIToken string
CaddyAuthAPIToken string
RemnawaveSubscription string
DatabaseURL string
DefaultUserDays int
DefaultExternalSquadUUID string
DefaultInternalSquadUUIDs []string
}
func Load() (*Config, error) {
@@ -60,6 +64,28 @@ func Load() (*Config, error) {
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{
BotToken: token,
BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"),
@@ -69,5 +95,9 @@ func Load() (*Config, error) {
RemnawaveAPIToken: apiToken,
CaddyAuthAPIToken: caddy,
RemnawaveSubscription: subURL,
DatabaseURL: dbURL,
DefaultUserDays: days,
DefaultExternalSquadUUID: strings.TrimSpace(os.Getenv("DEFAULT_EXTERNAL_SQUAD_UUID")),
DefaultInternalSquadUUIDs: internalSquads,
}, nil
}
+65
View File
@@ -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
}
+29
View File
@@ -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()
);
+75
View File
@@ -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
}
+123
View File
@@ -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)
}
+34 -7
View File
@@ -1,6 +1,7 @@
package remnawave
import (
"bytes"
"context"
"encoding/json"
"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) {
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) {
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) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
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("Accept", "application/json, text/html, */*")
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
if withBearer {
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()
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 {
+85
View File
@@ -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
}
+128
View File
@@ -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
}
+27 -4
View File
@@ -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.RemnawavePanelURL)
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) {
for {
select {
case <-ctx.Done():
log.Println("остановка бота…")
return
case update, ok := <-api.GetUpdatesChan(u):
if !ok {
return
}
handler.HandleUpdate(update)
}
}
}