From 1fb512163b20315c44ae0fe659b143841f308ba9 Mon Sep 17 00:00:00 2001 From: tgvpn Date: Thu, 21 May 2026 00:37:57 +0300 Subject: [PATCH] Add admin menu and Remnawave panel integration --- .env.example | 11 ++ README.md | 29 +++- internal/bot/handler.go | 254 +++++++++++++++++++++++++++++++++++ internal/config/config.go | 58 ++++++++ internal/remnawave/client.go | 162 ++++++++++++++++++++++ main.go | 47 ++----- 6 files changed, 524 insertions(+), 37 deletions(-) create mode 100644 internal/bot/handler.go create mode 100644 internal/config/config.go create mode 100644 internal/remnawave/client.go diff --git a/.env.example b/.env.example index 1eac311..7b0d796 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,15 @@ BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz # true — подробные логи API Telegram BOT_DEBUG=false +# Telegram user ID администратора (узнать: @userinfobot или @getidsbot) +TELEGRAM_ADMIN_ID=123456789 + +# Remnawave — панель 1 (https://docs.rw/) +REMNAWAVE_PANEL_NAME=Панель 1 +REMNAWAVE_PANEL_URL=https://panel.example.com +# Settings → API Tokens в панели Remnawave +REMNAWAVE_API_TOKEN=your_api_token_here +# Опционально, если перед панелью стоит Caddy с X-Api-Key +REMNAWAVE_CADDY_TOKEN= + # Docker Compose читает этот файл как .env (скопируйте: cp .env.example .env) diff --git a/README.md b/README.md index 3651250..816e735 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ cp .env.example .env ```env BOT_TOKEN=ваш_токен_от_BotFather BOT_DEBUG=false +TELEGRAM_ADMIN_ID=123456789 +REMNAWAVE_PANEL_NAME=Панель 1 +REMNAWAVE_PANEL_URL=https://panel.example.com +REMNAWAVE_API_TOKEN=токен_из_панели ``` > **Важно:** файл `.env` не попадает в git и не копируется в образ. Compose передаёт переменные в контейнер при старте. @@ -204,8 +208,20 @@ go build -o bot . | Переменная | Обязательно | Описание | |--------------|-------------|----------| -| `BOT_TOKEN` | да | Токен от @BotFather | -| `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) | +| `BOT_TOKEN` | да | Токен от @BotFather | +| `TELEGRAM_ADMIN_ID` | да | Числовой Telegram user ID администратора (например, [@userinfobot](https://t.me/userinfobot)) | +| `REMNAWAVE_PANEL_NAME` | нет | Название панели в админ-меню (по умолчанию «Панель 1») | +| `REMNAWAVE_PANEL_URL` | да | URL панели Remnawave, например `https://vpn.example.com` | +| `REMNAWAVE_API_TOKEN` | да | API-токен: панель → **Settings → API Tokens** ([документация](https://docs.rw/)) | +| `REMNAWAVE_CADDY_TOKEN` | нет | Доп. заголовок `X-Api-Key`, если панель за Caddy | +| `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) | + +### Админ-меню в боте + +Только пользователь с `TELEGRAM_ADMIN_ID`: + +- `/admin` — inline-меню (конфиг панели, проверка API, ссылка на [docs.rw](https://docs.rw/)) +- Кнопки снизу (после `/start`): «Конфиг панели», «Проверить панель» --- @@ -246,10 +262,11 @@ docker compose down --rmi local ## Устранение неполадок -### `BOT_TOKEN не задан` +### `BOT_TOKEN не задан` / `TELEGRAM_ADMIN_ID не задан` - Проверьте, что файл `.env` лежит рядом с `docker-compose.yml`. - В `.env` нет пробелов вокруг `=`: `BOT_TOKEN=123:ABC`, не `BOT_TOKEN = ...`. +- `TELEGRAM_ADMIN_ID` — только цифры, без `@username`. - После правки: `docker compose up -d --force-recreate`. ### `Authentication failed` / `401 Unauthorized` @@ -287,7 +304,11 @@ sudo usermod -aG docker $USER ``` tgvpn/ -├── main.go # логика бота +├── main.go +├── internal/ +│ ├── bot/ # обработчики Telegram, админ-меню +│ ├── config/ # переменные окружения +│ └── remnawave/ # клиент API панели ├── Dockerfile # multi-stage сборка ├── docker-compose.yml # оркестрация ├── .env.example # шаблон переменных diff --git a/internal/bot/handler.go b/internal/bot/handler.go new file mode 100644 index 0000000..ffdeb65 --- /dev/null +++ b/internal/bot/handler.go @@ -0,0 +1,254 @@ +package bot + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "telegramvpn/internal/config" + "telegramvpn/internal/remnawave" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +const docsURL = "https://docs.rw/" + +type Handler struct { + cfg *config.Config + api *tgbotapi.BotAPI + panel *remnawave.Client + admin int64 +} + +func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI) *Handler { + return &Handler{ + cfg: cfg, + api: api, + panel: remnawave.NewClient(cfg.RemnawaveURL, cfg.RemnawaveToken, cfg.RemnawaveCaddy), + admin: cfg.TelegramAdminID, + } +} + +func (h *Handler) HandleUpdate(update tgbotapi.Update) { + if update.CallbackQuery != nil { + h.handleCallback(update.CallbackQuery) + return + } + if update.Message == nil { + return + } + + chatID := update.Message.Chat.ID + userID := update.Message.From.ID + text := strings.TrimSpace(update.Message.Text) + + switch { + case text == "/start": + h.sendStart(chatID, userID, update.Message.From.FirstName) + case text == "/admin": + if !h.isAdmin(userID) { + h.sendText(chatID, "У вас нет доступа к админ-меню.") + return + } + h.sendAdminMenu(chatID, "Админ-меню VPN-панели Remnawave:") + case strings.HasPrefix(text, "/"): + h.sendText(chatID, "Неизвестная команда. Для начала — /start") + default: + if h.isAdmin(userID) { + switch text { + case "📋 Конфиг панели": + h.sendPanelConfig(chatID) + return + case "🔌 Проверить панель": + h.sendPanelCheck(chatID) + return + case "◀️ Выйти из админки": + h.sendText(chatID, "Админ-меню закрыто. /admin — снова открыть.") + return + } + } + h.sendText(chatID, "Напишите /start, чтобы начать.") + } +} + +func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) { + answer := tgbotapi.NewCallback(cq.ID, "") + if _, err := h.api.Request(answer); err != nil { + log.Printf("callback answer: %v", err) + } + + if !h.isAdmin(cq.From.ID) { + h.editOrSend(cq.Message.Chat.ID, cq.Message.MessageID, "Нет доступа.") + return + } + + switch cq.Data { + case "admin:config": + h.sendPanelConfig(cq.Message.Chat.ID) + case "admin:check": + h.sendPanelCheck(cq.Message.Chat.ID) + case "admin:menu": + h.sendAdminMenu(cq.Message.Chat.ID, "Админ-меню VPN-панели Remnawave:") + default: + h.editOrSend(cq.Message.Chat.ID, cq.Message.MessageID, "Неизвестное действие.") + } +} + +func (h *Handler) isAdmin(userID int64) bool { + return userID == h.admin +} + +func (h *Handler) sendStart(chatID, userID int64, firstName string) { + name := firstName + if name == "" { + name = "друг" + } + text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот на базе панели Remnawave.", name) + if h.isAdmin(userID) { + text += "\n\nКоманда /admin — настройки и проверка панели." + } + msg := tgbotapi.NewMessage(chatID, text) + if h.isAdmin(userID) { + msg.ReplyMarkup = adminReplyKeyboard() + } + h.send(msg) +} + +func (h *Handler) sendAdminMenu(chatID int64, title string) { + msg := tgbotapi.NewMessage(chatID, title) + msg.ReplyMarkup = adminInlineKeyboard() + h.send(msg) +} + +func (h *Handler) sendPanelConfig(chatID int64) { + text := fmt.Sprintf( + "⚙️ *%s* (Remnawave)\n\n"+ + "• URL: `%s`\n"+ + "• API token: `%s`\n"+ + "• Caddy token: %s\n\n"+ + "Токен API создаётся в панели: *Settings → API Tokens*.\n"+ + "Документация: %s", + escapeMarkdown(h.cfg.RemnawaveName), + escapeMarkdown(h.cfg.RemnawaveURL), + escapeMarkdown(maskSecret(h.cfg.RemnawaveToken)), + caddyStatus(h.cfg.RemnawaveCaddy), + docsURL, + ) + msg := tgbotapi.NewMessage(chatID, text) + msg.ParseMode = "Markdown" + msg.ReplyMarkup = adminInlineKeyboard() + h.send(msg) +} + +func (h *Handler) sendPanelCheck(chatID int64) { + h.sendText(chatID, "Проверяю подключение к панели…") + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + st, err := h.panel.Check(ctx) + if err != nil { + h.sendText(chatID, fmt.Sprintf("❌ %s\n\nПанель: %s", err.Error(), h.cfg.RemnawaveURL)) + return + } + + text := fmt.Sprintf( + "✅ %s\n\nПанель: *%s*\nURL: `%s`\nHTTP: %d", + st.Detail, + escapeMarkdown(h.cfg.RemnawaveName), + escapeMarkdown(h.cfg.RemnawaveURL), + st.StatusCode, + ) + if st.Users > 0 || st.Nodes > 0 { + text += fmt.Sprintf("\n\n👥 Пользователей: %d\n📡 Нод: %d", st.Users, st.Nodes) + } + msg := tgbotapi.NewMessage(chatID, text) + msg.ParseMode = "Markdown" + msg.ReplyMarkup = adminInlineKeyboard() + h.send(msg) +} + +func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup { + return tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("📋 Конфиг панели", "admin:config"), + tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить панель", "admin:check"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonURL("📖 Документация Remnawave", docsURL), + ), + ) +} + +func adminReplyKeyboard() tgbotapi.ReplyKeyboardMarkup { + return tgbotapi.ReplyKeyboardMarkup{ + Keyboard: [][]tgbotapi.KeyboardButton{ + { + tgbotapi.NewKeyboardButton("📋 Конфиг панели"), + tgbotapi.NewKeyboardButton("🔌 Проверить панель"), + }, + { + tgbotapi.NewKeyboardButton("◀️ Выйти из админки"), + }, + }, + ResizeKeyboard: true, + OneTimeKeyboard: false, + } +} + +func (h *Handler) sendText(chatID int64, text string) { + h.send(tgbotapi.NewMessage(chatID, text)) +} + +func (h *Handler) send(msg tgbotapi.MessageConfig) { + if _, err := h.api.Send(msg); err != nil { + log.Printf("ошибка отправки: %v", err) + } +} + +func (h *Handler) editOrSend(chatID int64, messageID int, text string) { + edit := tgbotapi.NewEditMessageText(chatID, messageID, text) + if _, err := h.api.Send(edit); err != nil { + h.sendText(chatID, text) + } +} + +func maskSecret(s string) string { + if len(s) <= 8 { + return "****" + } + return s[:4] + "…" + s[len(s)-4:] +} + +func caddyStatus(token string) string { + if token == "" { + return "не задан (опционально)" + } + return "`" + escapeMarkdown(maskSecret(token)) + "`" +} + +func escapeMarkdown(s string) string { + replacer := strings.NewReplacer( + "_", "\\_", + "*", "\\*", + "[", "\\[", + "]", "\\]", + "(", "\\(", + ")", "\\)", + "~", "\\~", + "`", "\\`", + ">", "\\>", + "#", "\\#", + "+", "\\+", + "-", "\\-", + "=", "\\=", + "|", "\\|", + "{", "\\{", + "}", "\\}", + ".", "\\.", + "!", "\\!", + ) + return replacer.Replace(s) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1d98d9c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,58 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +type Config struct { + BotToken string + BotDebug bool + TelegramAdminID int64 + RemnawaveName string + RemnawaveURL string + RemnawaveToken string + RemnawaveCaddy string +} + +func Load() (*Config, error) { + token := strings.TrimSpace(os.Getenv("BOT_TOKEN")) + if token == "" { + return nil, fmt.Errorf("BOT_TOKEN не задан") + } + + adminID, err := strconv.ParseInt(strings.TrimSpace(os.Getenv("TELEGRAM_ADMIN_ID")), 10, 64) + if err != nil || adminID <= 0 { + return nil, fmt.Errorf("TELEGRAM_ADMIN_ID не задан или неверный") + } + + panelURL := strings.TrimRight(strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_URL")), "/") + if panelURL == "" { + return nil, fmt.Errorf("REMNAWAVE_PANEL_URL не задан") + } + if !strings.HasPrefix(panelURL, "http://") && !strings.HasPrefix(panelURL, "https://") { + return nil, fmt.Errorf("REMNAWAVE_PANEL_URL должен начинаться с http:// или https://") + } + + panelToken := strings.TrimSpace(os.Getenv("REMNAWAVE_API_TOKEN")) + if panelToken == "" { + return nil, fmt.Errorf("REMNAWAVE_API_TOKEN не задан (создайте в панели: Settings → API Tokens)") + } + + name := strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_NAME")) + if name == "" { + name = "Панель 1" + } + + return &Config{ + BotToken: token, + BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"), + TelegramAdminID: adminID, + RemnawaveName: name, + RemnawaveURL: panelURL, + RemnawaveToken: panelToken, + RemnawaveCaddy: strings.TrimSpace(os.Getenv("REMNAWAVE_CADDY_TOKEN")), + }, nil +} diff --git a/internal/remnawave/client.go b/internal/remnawave/client.go new file mode 100644 index 0000000..4327e12 --- /dev/null +++ b/internal/remnawave/client.go @@ -0,0 +1,162 @@ +package remnawave + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type Client struct { + baseURL string + token string + caddyToken string + http *http.Client +} + +type PanelStatus struct { + OK bool + StatusCode int + Users int + Nodes int + Detail string +} + +func NewClient(baseURL, apiToken, caddyToken string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: apiToken, + caddyToken: caddyToken, + http: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +func (c *Client) Check(ctx context.Context) (PanelStatus, error) { + st := PanelStatus{} + + resp, body, err := c.get(ctx, "/api/system/stats/recap") + if err != nil { + return st, err + } + st.StatusCode = resp.StatusCode + + switch resp.StatusCode { + case http.StatusOK: + st.OK = true + st.Detail = "API панели отвечает" + case http.StatusUnauthorized, http.StatusForbidden: + return st, fmt.Errorf("доступ запрещён (HTTP %d): проверьте REMNAWAVE_API_TOKEN", resp.StatusCode) + default: + return st, fmt.Errorf("панель вернула HTTP %d: %s", resp.StatusCode, trimBody(body, 200)) + } + + users, err := c.countFromEndpoint(ctx, "/api/users", "users") + if err == nil { + st.Users = users + } + + nodes, err := c.countFromEndpoint(ctx, "/api/nodes", "nodes") + if err == nil { + st.Nodes = nodes + } + + return st, nil +} + +func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) (int, error) { + resp, body, err := c.get(ctx, path) + if err != nil { + return 0, err + } + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("HTTP %d", resp.StatusCode) + } + return parseCount(body, arrayKey), nil +} + +func (c *Client) get(ctx context.Context, path string) (*http.Response, []byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return nil, nil, err + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + if c.caddyToken != "" { + req.Header.Set("X-Api-Key", c.caddyToken) + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("нет связи с панелью: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return resp, nil, err + } + return resp, body, nil +} + +func parseCount(body []byte, arrayKey string) int { + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return 0 + } + + if n := countInRaw(raw["response"], arrayKey); n > 0 { + return n + } + return countInRaw(json.RawMessage(body), arrayKey) +} + +func countInRaw(data json.RawMessage, arrayKey string) int { + if len(data) == 0 { + return 0 + } + + var obj map[string]json.RawMessage + if err := json.Unmarshal(data, &obj); err != nil { + var arr []json.RawMessage + if err := json.Unmarshal(data, &arr); err == nil { + return len(arr) + } + return 0 + } + + if totalRaw, ok := obj["total"]; ok { + var total int + if err := json.Unmarshal(totalRaw, &total); err == nil && total > 0 { + return total + } + } + + if items, ok := obj[arrayKey]; ok { + var arr []json.RawMessage + if err := json.Unmarshal(items, &arr); err == nil { + return len(arr) + } + } + + for _, v := range obj { + var arr []json.RawMessage + if err := json.Unmarshal(v, &arr); err == nil && len(arr) > 0 { + return len(arr) + } + } + return 0 +} + +func trimBody(b []byte, max int) string { + s := strings.TrimSpace(string(b)) + if len(s) > max { + return s[:max] + "…" + } + return s +} diff --git a/main.go b/main.go index 236f316..928664c 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,9 @@ package main import ( "log" - "os" + + "telegramvpn/internal/bot" + "telegramvpn/internal/config" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/joho/godotenv" @@ -11,47 +13,26 @@ import ( func main() { _ = godotenv.Load() - token := os.Getenv("BOT_TOKEN") - if token == "" { - log.Fatal("BOT_TOKEN не задан. Скопируйте .env.example в .env и укажите токен от @BotFather") + cfg, err := config.Load() + if err != nil { + log.Fatal(err) } - bot, err := tgbotapi.NewBotAPI(token) + api, err := tgbotapi.NewBotAPI(cfg.BotToken) if err != nil { log.Fatalf("не удалось подключиться к Telegram: %v", err) } + api.Debug = cfg.BotDebug - bot.Debug = os.Getenv("BOT_DEBUG") == "true" - log.Printf("бот @%s запущен", bot.Self.UserName) + log.Printf("бот @%s запущен, админ ID %d, панель %q (%s)", + api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawaveName, cfg.RemnawaveURL) + + handler := bot.NewHandler(cfg, api) u := tgbotapi.NewUpdate(0) u.Timeout = 60 - updates := bot.GetUpdatesChan(u) - - for update := range updates { - if update.Message == nil { - continue - } - - chatID := update.Message.Chat.ID - text := update.Message.Text - - var reply string - switch text { - case "/start": - name := update.Message.From.FirstName - if name == "" { - name = "друг" - } - reply = "Привет, " + name + "!\n\nЯ VPN-бот. Пока умею только здороваться — дальше добавим функции." - default: - reply = "Напишите /start, чтобы начать." - } - - msg := tgbotapi.NewMessage(chatID, reply) - if _, err := bot.Send(msg); err != nil { - log.Printf("ошибка отправки в чат %d: %v", chatID, err) - } + for update := range api.GetUpdatesChan(u) { + handler.HandleUpdate(update) } }