package bot import ( "context" "fmt" "log" "strings" "time" "telegramvpn/internal/config" "telegramvpn/internal/db" "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 database *db.DB admin int64 } func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI, database *db.DB) *Handler { return &Handler{ cfg: cfg, api: api, panel: remnawave.NewClient(cfg.RemnawavePanelURL, cfg.RemnawaveAPIToken, cfg.CaddyAuthAPIToken), database: database, admin: cfg.TelegramAdminID, } } func (h *Handler) RegisterCommands() { commands := []tgbotapi.BotCommand{ {Command: "start", Description: "Начать"}, {Command: "admin", Description: "Админ-меню Remnawave (панель 1)"}, } scope := tgbotapi.BotCommandScope{Type: "chat", ChatID: h.admin} cfg := tgbotapi.SetMyCommandsConfig{ Commands: commands, Scope: &scope, } if _, err := h.api.Request(cfg); err != nil { log.Printf("не удалось зарегистрировать команды для админа: %v", err) } } 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, update.Message.From.UserName) case strings.HasPrefix(text, "/admin"): h.handleAdminCommand(chatID, userID, text) case strings.HasPrefix(text, "/"): h.sendText(chatID, "Неизвестная команда. Для начала — /start") default: if h.isAdmin(userID) && h.handleWizardMessage(chatID, userID, text) { return } if h.isAdmin(userID) { switch text { case "📋 Конфиг панели": h.sendPanelConfig(chatID) return case "🔌 Проверить панель": h.sendPanelCheck(chatID) return case "👤 Создать пользователя": h.startUserWizard(chatID, userID) return case "📡 Сквады": h.sendSquadsList(chatID) return case "◀️ Выйти из админки": h.sendText(chatID, "Админ-меню закрыто. /admin — снова открыть.") return } } h.sendText(chatID, "Напишите /start, чтобы начать.") } } func (h *Handler) handleAdminCommand(chatID, userID int64, text string) { if !h.isAdmin(userID) { h.sendText(chatID, "У вас нет доступа к админ-меню.") return } args := strings.Fields(text) if len(args) == 1 { h.sendAdminMenu(chatID) return } switch args[1] { case "check", "проверка": h.sendPanelCheck(chatID) case "config", "конфиг": h.sendPanelConfig(chatID) case "user", "пользователь", "squads", "сквады", "assign", "сквад", "cancel", "отмена", "help": h.handleAdminUsersSubcommand(chatID, userID, args[2:]) default: h.sendAdminHelp(chatID) } } 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 } if h.handleWizardCallback(cq) { return } switch cq.Data { case "admin:user": h.startUserWizard(cq.Message.Chat.ID, cq.From.ID) case "admin:squads": h.sendSquadsList(cq.Message.Chat.ID) 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) 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, tgUsername string) { ctx := context.Background() _ = h.database.UpsertTelegramUser(ctx, userID, tgUsername, firstName) name := firstName if name == "" { name = "друг" } text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот на базе панели Remnawave.", name) if h.isAdmin(userID) { text += "\n\n/admin — админ-меню\n/admin user — создать пользователя\n/admin squads — сквады" } msg := tgbotapi.NewMessage(chatID, text) if h.isAdmin(userID) { msg.ReplyMarkup = adminReplyKeyboard() } h.send(msg) } func (h *Handler) sendAdminMenu(chatID int64) { text := fmt.Sprintf( "🛠 *Админ-меню* — %s\n\n"+ "Команды:\n"+ "• /admin — это меню\n"+ "• /admin check — проверка панели, API и подписки\n"+ "• /admin config — конфиг панели\n"+ "• /admin user — создать пользователя\n"+ "• /admin squads — список сквадов\n"+ "• /admin assign <логин> — назначить сквады\n\n"+ "Или кнопки ниже.", escapeMarkdown(h.cfg.RemnawaveName), ) msg := tgbotapi.NewMessage(chatID, text) msg.ParseMode = "Markdown" msg.ReplyMarkup = adminInlineKeyboard() h.send(msg) } func (h *Handler) sendAdminHelp(chatID int64) { h.sendText(chatID, "Команды:\n/admin — меню\n/admin check\n/admin config\n/admin user\n/admin squads\n/admin assign <логин>") } func (h *Handler) sendPanelConfig(chatID int64) { subURL := h.cfg.RemnawaveSubscription if subURL == "" { subURL = "не задана (опционально)" } caddy := h.cfg.CaddyAuthAPIToken if caddy == "" { caddy = "не задан" } else { caddy = maskSecret(caddy) } text := fmt.Sprintf( "⚙️ %s (Remnawave)\n\n"+ "REMNAWAVE_PANEL_URL:\n%s\n"+ "(API: %s/api/... + Bearer REMNAWAVE_API_TOKEN)\n\n"+ "REMNAWAVE_SUBSCRIPTION_URL (опц.):\n%s\n\n"+ "REMNAWAVE_API_TOKEN: %s\n"+ "CADDY_AUTH_API_TOKEN: %s\n\n"+ "Токен: Remnawave Settings → API Tokens\n"+ "Док: %s", h.cfg.RemnawaveName, h.cfg.RemnawavePanelURL, h.cfg.RemnawavePanelURL, subURL, maskSecret(h.cfg.RemnawaveAPIToken), caddy, "https://docs.rw/docs/install/subscription-page/bundled", ) msg := tgbotapi.NewMessage(chatID, text) msg.ReplyMarkup = adminInlineKeyboard() h.send(msg) } func (h *Handler) sendPanelCheck(chatID int64) { h.sendText(chatID, fmt.Sprintf("Проверяю «%s»: панель, API, подписка…", h.cfg.RemnawaveName)) ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() report := h.panel.FullCheck(ctx, h.cfg.RemnawaveSubscription) text := remnawave.FormatReport(report, h.cfg.RemnawaveName) msg := tgbotapi.NewMessage(chatID, text) msg.ReplyMarkup = adminInlineKeyboard() h.send(msg) } func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup { return tgbotapi.NewInlineKeyboardMarkup( tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("👤 Создать пользователя", "admin:user"), tgbotapi.NewInlineKeyboardButtonData("📡 Сквады", "admin:squads"), ), tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить", "admin:check"), tgbotapi.NewInlineKeyboardButtonData("📋 Конфиг", "admin:config"), ), tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonURL("📖 Документация", docsURL), ), ) } func adminReplyKeyboard() tgbotapi.ReplyKeyboardMarkup { return tgbotapi.ReplyKeyboardMarkup{ Keyboard: [][]tgbotapi.KeyboardButton{ { tgbotapi.NewKeyboardButton("👤 Создать пользователя"), tgbotapi.NewKeyboardButton("📡 Сквады"), }, { 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.sendReturnErr(msg); err != nil { log.Printf("ошибка отправки: %v", err) } } func (h *Handler) sendReturnErr(msg tgbotapi.MessageConfig) error { _, err := h.api.Send(msg) return 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) }