Add admin menu and Remnawave panel integration
This commit is contained in:
@@ -4,4 +4,15 @@ BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
|||||||
# true — подробные логи API Telegram
|
# true — подробные логи API Telegram
|
||||||
BOT_DEBUG=false
|
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)
|
# Docker Compose читает этот файл как .env (скопируйте: cp .env.example .env)
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ cp .env.example .env
|
|||||||
```env
|
```env
|
||||||
BOT_TOKEN=ваш_токен_от_BotFather
|
BOT_TOKEN=ваш_токен_от_BotFather
|
||||||
BOT_DEBUG=false
|
BOT_DEBUG=false
|
||||||
|
TELEGRAM_ADMIN_ID=123456789
|
||||||
|
REMNAWAVE_PANEL_NAME=Панель 1
|
||||||
|
REMNAWAVE_PANEL_URL=https://panel.example.com
|
||||||
|
REMNAWAVE_API_TOKEN=токен_из_панели
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Важно:** файл `.env` не попадает в git и не копируется в образ. Compose передаёт переменные в контейнер при старте.
|
> **Важно:** файл `.env` не попадает в git и не копируется в образ. Compose передаёт переменные в контейнер при старте.
|
||||||
@@ -204,8 +208,20 @@ go build -o bot .
|
|||||||
|
|
||||||
| Переменная | Обязательно | Описание |
|
| Переменная | Обязательно | Описание |
|
||||||
|--------------|-------------|----------|
|
|--------------|-------------|----------|
|
||||||
| `BOT_TOKEN` | да | Токен от @BotFather |
|
| `BOT_TOKEN` | да | Токен от @BotFather |
|
||||||
| `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) |
|
| `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` лежит рядом с `docker-compose.yml`.
|
||||||
- В `.env` нет пробелов вокруг `=`: `BOT_TOKEN=123:ABC`, не `BOT_TOKEN = ...`.
|
- В `.env` нет пробелов вокруг `=`: `BOT_TOKEN=123:ABC`, не `BOT_TOKEN = ...`.
|
||||||
|
- `TELEGRAM_ADMIN_ID` — только цифры, без `@username`.
|
||||||
- После правки: `docker compose up -d --force-recreate`.
|
- После правки: `docker compose up -d --force-recreate`.
|
||||||
|
|
||||||
### `Authentication failed` / `401 Unauthorized`
|
### `Authentication failed` / `401 Unauthorized`
|
||||||
@@ -287,7 +304,11 @@ sudo usermod -aG docker $USER
|
|||||||
|
|
||||||
```
|
```
|
||||||
tgvpn/
|
tgvpn/
|
||||||
├── main.go # логика бота
|
├── main.go
|
||||||
|
├── internal/
|
||||||
|
│ ├── bot/ # обработчики Telegram, админ-меню
|
||||||
|
│ ├── config/ # переменные окружения
|
||||||
|
│ └── remnawave/ # клиент API панели
|
||||||
├── Dockerfile # multi-stage сборка
|
├── Dockerfile # multi-stage сборка
|
||||||
├── docker-compose.yml # оркестрация
|
├── docker-compose.yml # оркестрация
|
||||||
├── .env.example # шаблон переменных
|
├── .env.example # шаблон переменных
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
|
"telegramvpn/internal/bot"
|
||||||
|
"telegramvpn/internal/config"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -11,47 +13,26 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
|
|
||||||
token := os.Getenv("BOT_TOKEN")
|
cfg, err := config.Load()
|
||||||
if token == "" {
|
if err != nil {
|
||||||
log.Fatal("BOT_TOKEN не задан. Скопируйте .env.example в .env и укажите токен от @BotFather")
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bot, err := tgbotapi.NewBotAPI(token)
|
api, err := tgbotapi.NewBotAPI(cfg.BotToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("не удалось подключиться к Telegram: %v", err)
|
log.Fatalf("не удалось подключиться к Telegram: %v", err)
|
||||||
}
|
}
|
||||||
|
api.Debug = cfg.BotDebug
|
||||||
|
|
||||||
bot.Debug = os.Getenv("BOT_DEBUG") == "true"
|
log.Printf("бот @%s запущен, админ ID %d, панель %q (%s)",
|
||||||
log.Printf("бот @%s запущен", bot.Self.UserName)
|
api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawaveName, cfg.RemnawaveURL)
|
||||||
|
|
||||||
|
handler := bot.NewHandler(cfg, api)
|
||||||
|
|
||||||
u := tgbotapi.NewUpdate(0)
|
u := tgbotapi.NewUpdate(0)
|
||||||
u.Timeout = 60
|
u.Timeout = 60
|
||||||
|
|
||||||
updates := bot.GetUpdatesChan(u)
|
for update := range api.GetUpdatesChan(u) {
|
||||||
|
handler.HandleUpdate(update)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user