Add admin menu and Remnawave panel integration

This commit is contained in:
tgvpn
2026-05-21 00:37:57 +03:00
parent 20872232b7
commit 1fb512163b
6 changed files with 524 additions and 37 deletions
+11
View File
@@ -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)
+25 -4
View File
@@ -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 # шаблон переменных
+254
View File
@@ -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)
}
+58
View File
@@ -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
}
+162
View File
@@ -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
}
+14 -33
View File
@@ -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)
}
}