diff --git a/internal/bot/handler.go b/internal/bot/handler.go index 070d550..e4f0195 100644 --- a/internal/bot/handler.go +++ b/internal/bot/handler.go @@ -76,30 +76,25 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) { if h.isAdmin(userID) && h.handleWizardMessage(chatID, userID, text) { return } - if text == "📲 Получить конфиг (1 день)" || strings.HasPrefix(text, "📲 Получить конфиг") { + 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 } - 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 - } + // Старые подписи reply-клавиатуры (если остались у пользователя) + if h.isAdmin(userID) && h.handleLegacyAdminReply(chatID, userID, text) { + return } - h.sendText(chatID, "Напишите /start, чтобы начать.") + h.sendText(chatID, "Напишите /start или нажмите 🏠 Главная в меню.") } } @@ -133,33 +128,54 @@ func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) { log.Printf("callback answer: %v", err) } - if !h.isAdmin(cq.From.ID) { - h.editOrSend(cq.Message.Chat.ID, cq.Message.MessageID, "Нет доступа.") + 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 cq.Data == "user:config" { - h.handleUserConfig(cq.Message.Chat.ID, cq.From.ID) - return + if strings.HasPrefix(cq.Data, "wz:") { + if !h.isAdmin(userID) { + h.callbackDenied(cq) + return + } + if h.handleWizardCallback(cq) { + return + } } - if h.handleWizardCallback(cq) { + if !h.isAdmin(userID) { + h.callbackDenied(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) + 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(cq.Message.Chat.ID, cq.Message.MessageID, "Неизвестное действие.") + 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) } } @@ -168,64 +184,82 @@ func (h *Handler) isAdmin(userID int64) bool { } 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 := h.cfg.TrialUserDays - if days <= 0 { - days = 1 - } - text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот. Нажмите кнопку ниже — получите конфиг на %d дн.\nИли команда /config", name, days) + 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 — админ-меню" + text += "\n\nВы администратор: кнопка «🛠 Админ-панель» или /admin" } msg := tgbotapi.NewMessage(chatID, text) - msg.ReplyMarkup = h.startInlineKeyboard(userID) + msg.ReplyMarkup = userMenuKeyboard(h.cfg, userID, h.admin) h.send(msg) } -func (h *Handler) startInlineKeyboard(userID int64) tgbotapi.InlineKeyboardMarkup { - rows := [][]tgbotapi.InlineKeyboardButton{ - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(h.userConfigButtonLabel(), "user:config"), - ), +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) } - if h.isAdmin(userID) { - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("🛠 Админ-меню", "admin:menu"), - )) - } - return tgbotapi.NewInlineKeyboardMarkup(rows...) } -func (h *Handler) userConfigButtonLabel() string { - days := h.cfg.TrialUserDays - if days <= 0 { - days = 1 +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 fmt.Sprintf("📲 Получить конфиг (%d дн.)", days) + return true } 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"+ - "Или кнопки ниже.", + "🛠 *Админ-панель* — %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 = adminInlineKeyboard() + msg.ReplyMarkup = adminMenuKeyboard() h.send(msg) } @@ -262,7 +296,7 @@ func (h *Handler) sendPanelConfig(chatID int64) { "https://docs.rw/docs/install/subscription-page/bundled", ) msg := tgbotapi.NewMessage(chatID, text) - msg.ReplyMarkup = adminInlineKeyboard() + msg.ReplyMarkup = adminContextKeyboard() h.send(msg) } @@ -276,46 +310,10 @@ func (h *Handler) sendPanelCheck(chatID int64) { text := remnawave.FormatReport(report, h.cfg.RemnawaveName) msg := tgbotapi.NewMessage(chatID, text) - msg.ReplyMarkup = adminInlineKeyboard() + msg.ReplyMarkup = adminContextKeyboard() 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)) } diff --git a/internal/bot/keyboards.go b/internal/bot/keyboards.go new file mode 100644 index 0000000..d7ec091 --- /dev/null +++ b/internal/bot/keyboards.go @@ -0,0 +1,108 @@ +package bot + +import ( + "fmt" + + "telegramvpn/internal/config" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +const ( + cbUserConfig = "user:config" + cbUserHome = "user:home" + cbAdminMenu = "admin:menu" + cbAdminUser = "admin:user" + cbAdminSquads = "admin:squads" + cbAdminCheck = "admin:check" + cbAdminConfig = "admin:config" +) + +func trialDays(cfg *config.Config) int { + d := cfg.TrialUserDays + if d <= 0 { + return 1 + } + return d +} + +func userConfigLabel(cfg *config.Config) string { + return fmt.Sprintf("🔐 Подключить VPN · %d дн.", trialDays(cfg)) +} + +func userHomeLabel() string { + return "🏠 Главная" +} + +func adminPanelLabel() string { + return "🛠 Админ-панель" +} + +// userMenuKeyboard — главное меню пользователя (и выход из админки). +func userMenuKeyboard(cfg *config.Config, userID, adminID int64) tgbotapi.InlineKeyboardMarkup { + rows := [][]tgbotapi.InlineKeyboardButton{ + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(userConfigLabel(cfg), cbUserConfig), + ), + } + if userID == adminID { + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(adminPanelLabel(), cbAdminMenu), + )) + } + return tgbotapi.NewInlineKeyboardMarkup(rows...) +} + +// configResultKeyboard — после выдачи конфига: ссылка + возврат в меню. +func configResultKeyboard(cfg *config.Config, userID, adminID int64, subscriptionURL string) tgbotapi.InlineKeyboardMarkup { + var rows [][]tgbotapi.InlineKeyboardButton + if subscriptionURL != "" { + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonURL("🔗 Открыть подписку", subscriptionURL), + )) + } + rows = append(rows, + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(userConfigLabel(cfg), cbUserConfig), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(userHomeLabel(), cbUserHome), + ), + ) + if userID == adminID { + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(adminPanelLabel(), cbAdminMenu), + )) + } + return tgbotapi.NewInlineKeyboardMarkup(rows...) +} + +// adminMenuKeyboard — панель администратора. +func adminMenuKeyboard() tgbotapi.InlineKeyboardMarkup { + return tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("👤 Новый пользователь", cbAdminUser), + tgbotapi.NewInlineKeyboardButtonData("📡 Сквады", cbAdminSquads), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🔌 Проверка API", cbAdminCheck), + tgbotapi.NewInlineKeyboardButtonData("⚙️ Настройки", cbAdminConfig), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonURL("📖 Remnawave Docs", docsURL), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(userHomeLabel(), cbUserHome), + ), + ) +} + +// adminContextKeyboard — под ответами админки (проверка, конфиг и т.д.). +func adminContextKeyboard() tgbotapi.InlineKeyboardMarkup { + return tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🛠 В админ-панель", cbAdminMenu), + tgbotapi.NewInlineKeyboardButtonData(userHomeLabel(), cbUserHome), + ), + ) +} diff --git a/internal/bot/user_config.go b/internal/bot/user_config.go index 69b2a23..f9a727d 100644 --- a/internal/bot/user_config.go +++ b/internal/bot/user_config.go @@ -8,6 +8,8 @@ import ( "telegramvpn/internal/db" "telegramvpn/internal/remnawave" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) func (h *Handler) handleUserConfig(chatID, telegramID int64) { @@ -26,7 +28,7 @@ func (h *Handler) handleUserConfig(chatID, telegramID int64) { } if existing != nil && existing.ExpireAt != nil && existing.ExpireAt.After(time.Now()) { link := h.resolveSubscriptionLink(ctx, existing.RemnawaveUsername, telegramID) - h.sendConfigMessage(chatID, days, existing.RemnawaveUsername, *existing.ExpireAt, link) + h.sendConfigMessage(chatID, telegramID, days, existing.RemnawaveUsername, *existing.ExpireAt, link) return } @@ -37,7 +39,7 @@ func (h *Handler) handleUserConfig(chatID, telegramID int64) { link = h.subscriptionLink(panelUser.ShortUUID) } _ = h.saveVPNFromPanel(ctx, telegramID, panelUser) - h.sendConfigMessage(chatID, days, panelUser.Username, panelUser.ExpireAt, link) + h.sendConfigMessage(chatID, telegramID, days, panelUser.Username, panelUser.ExpireAt, link) return } @@ -71,7 +73,7 @@ func (h *Handler) handleUserConfig(chatID, telegramID int64) { if link == "" { link = h.subscriptionLink(u.ShortUUID) } - h.sendConfigMessage(chatID, days, u.Username, u.ExpireAt, link) + h.sendConfigMessage(chatID, telegramID, days, u.Username, u.ExpireAt, link) } func (h *Handler) saveVPNFromPanel(ctx context.Context, telegramID int64, u *remnawave.PanelUser) error { @@ -116,21 +118,22 @@ func (h *Handler) subscriptionLink(shortUUID string) string { return "" } -func (h *Handler) sendConfigMessage(chatID int64, days int, username string, expireAt time.Time, link string) { +func (h *Handler) sendConfigMessage(chatID, telegramID int64, days int, username string, expireAt time.Time, link string) { text := fmt.Sprintf( - "✅ Ваш VPN-конфиг готов\n\n"+ - "Срок: %d дн. (до %s)\n"+ - "Логин: %s\n", + "✅ VPN готов\n\n"+ + "⏱ Срок: %d дн. (до %s)\n"+ + "👤 Логин: %s\n", days, expireAt.Local().Format("02.01.2006 15:04"), username, ) if link != "" { - text += "\n🔗 Ссылка подписки (добавьте в приложение):\n" + link + text += "\n🔗 Ссылка подписки — кнопка ниже или:\n" + link } else { - text += "\n⚠️ Ссылка подписки не настроена. Администратору нужно указать REMNAWAVE_SUBSCRIPTION_URL в .env" + text += "\n\n⚠️ Ссылка не настроена — REMNAWAVE_SUBSCRIPTION_URL в .env" } - text += "\n\nИмпортируйте ссылку в V2rayNG, Hiddify, Streisand и др." - h.sendText(chatID, text) + msg := tgbotapi.NewMessage(chatID, text) + msg.ReplyMarkup = configResultKeyboard(h.cfg, telegramID, h.admin, link) + h.send(msg) }