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) }