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() { public := []tgbotapi.BotCommand{ {Command: "start", Description: "Начать"}, {Command: "config", Description: "Получить VPN-конфиг"}, } if _, err := h.api.Request(tgbotapi.NewSetMyCommands(public...)); err != nil { log.Printf("команды (все пользователи): %v", err) } admin := append(public, tgbotapi.BotCommand{Command: "admin", Description: "Админ-меню"}) scope := tgbotapi.BotCommandScope{Type: "chat", ChatID: h.admin} if _, err := h.api.Request(tgbotapi.SetMyCommandsConfig{Commands: admin, Scope: &scope}); 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 text == "/config", text == "/getconfig": h.handleUserConfig(chatID, userID) 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 } switch text { case userHomeLabel(), "/menu": h.sendUserMenu(chatID, userID, update.Message.From.FirstName, update.Message.From.UserName) return case adminPanelLabel(), "🛠 Админ-меню": if h.isAdmin(userID) { h.sendAdminMenu(chatID) } return } if h.isUserConfigButtonText(text) { h.handleUserConfig(chatID, userID) return } // Старые подписи reply-клавиатуры (если остались у пользователя) if h.isAdmin(userID) && h.handleLegacyAdminReply(chatID, userID, text) { 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) } chatID := cq.Message.Chat.ID userID := cq.From.ID switch cq.Data { case cbUserConfig: h.handleUserConfig(chatID, userID) return case cbUserHome: h.sendUserMenu(chatID, userID, cq.From.FirstName, cq.From.UserName) return } if strings.HasPrefix(cq.Data, "wz:") { if !h.isAdmin(userID) { h.callbackDenied(cq) return } if h.handleWizardCallback(cq) { return } } if !h.isAdmin(userID) { h.callbackDenied(cq) return } switch cq.Data { case cbAdminUser: h.startUserWizard(chatID, userID) case cbAdminSquads: h.sendSquadsList(chatID) case cbAdminConfig: h.sendPanelConfig(chatID) case cbAdminCheck: h.sendPanelCheck(chatID) case cbAdminMenu: h.sendAdminMenu(chatID) default: h.editOrSend(chatID, cq.Message.MessageID, "Неизвестное действие.") } } func (h *Handler) callbackDenied(cq *tgbotapi.CallbackQuery) { cb := tgbotapi.NewCallback(cq.ID, "Нет доступа") cb.ShowAlert = true if _, err := h.api.Request(cb); err != nil { log.Printf("callback alert: %v", err) } } func (h *Handler) isAdmin(userID int64) bool { return userID == h.admin } func (h *Handler) sendStart(chatID, userID int64, firstName, tgUsername string) { h.sendUserMenu(chatID, userID, firstName, tgUsername) } func (h *Handler) sendUserMenu(chatID, userID int64, firstName, tgUsername string) { ctx := context.Background() _ = h.database.UpsertTelegramUser(ctx, userID, tgUsername, firstName) h.dismissReplyKeyboard(chatID) name := firstName if name == "" { name = "друг" } days := trialDays(h.cfg) text := fmt.Sprintf( "👋 Привет, %s!\n\n"+ "🔐 Trial VPN — %d дн.\n"+ "Нажмите кнопку ниже или /config\n\n"+ "Импорт: V2rayNG, Hiddify, Streisand и др.", name, days, ) if h.isAdmin(userID) { text += "\n\nВы администратор: кнопка «🛠 Админ-панель» или /admin" } msg := tgbotapi.NewMessage(chatID, text) msg.ReplyMarkup = userMenuKeyboard(h.cfg, userID, h.admin) h.send(msg) } func (h *Handler) dismissReplyKeyboard(chatID int64) { rm := tgbotapi.NewMessage(chatID, "\u200b") rm.ReplyMarkup = tgbotapi.NewRemoveKeyboard(true) rm.DisableNotification = true if _, err := h.api.Send(rm); err != nil { log.Printf("remove reply keyboard: %v", err) } } func (h *Handler) isUserConfigButtonText(text string) bool { return text == userConfigLabel(h.cfg) || strings.HasPrefix(text, "🔐 ") || strings.HasPrefix(text, "📲 Получить конфиг") } func (h *Handler) handleLegacyAdminReply(chatID, userID int64, text string) bool { switch text { case "📋 Конфиг панели", "⚙️ Настройки": h.sendPanelConfig(chatID) case "🔌 Проверить панель", "🔌 Проверка API": h.sendPanelCheck(chatID) case "👤 Создать пользователя", "👤 Новый пользователь": h.startUserWizard(chatID, userID) case "📡 Сквады": h.sendSquadsList(chatID) case "◀️ Выйти из админки": h.sendUserMenu(chatID, userID, "", "") return true default: return false } return true } func (h *Handler) sendAdminMenu(chatID int64) { text := fmt.Sprintf( "🛠 *Админ-панель* — %s\n\n"+ "• /admin check — проверка API\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 = adminMenuKeyboard() 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 = adminContextKeyboard() 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 = adminContextKeyboard() h.send(msg) } 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) }