255 lines
6.5 KiB
Go
255 lines
6.5 KiB
Go
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)
|
|
}
|