23f5e782f8
Unified inline menus, user callbacks for all users, Home button to exit admin panel. Co-authored-by: Cursor <cursoragent@cursor.com>
376 lines
10 KiB
Go
376 lines
10 KiB
Go
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)
|
|
}
|