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_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
@@ -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]: #
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
module telegramvpn
|
||||
|
||||
go 1.22
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/joho/godotenv v1.5.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -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"
|
||||
|
||||
"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("📋 Конфиг панели"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user