package bot import ( "context" "fmt" "log" "regexp" "strconv" "strings" "time" "telegramvpn/internal/db" "telegramvpn/internal/remnawave" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,36}$`) func (h *Handler) handleAdminUsersSubcommand(chatID, adminID int64, args []string) { switch { case len(args) == 0 || args[0] == "help": h.sendText(chatID, "Пользователи Remnawave:\n\n"+ "/admin user — мастер создания\n"+ "/admin user <логин> [дней] — быстрое создание\n"+ "/admin squads — список сквадов\n"+ "/admin assign <логин> — назначить сквады (мастер)\n"+ "/admin cancel — отменить мастер") case args[0] == "cancel", args[0] == "отмена": _ = h.database.ClearWizard(context.Background(), adminID) h.sendText(chatID, "Мастер отменён.") case args[0] == "squads", args[0] == "сквады": h.sendSquadsList(chatID) case args[0] == "user", args[0] == "пользователь": if len(args) >= 2 { days := h.cfg.DefaultUserDays if len(args) >= 3 { if d, err := strconv.Atoi(args[2]); err == nil && d > 0 { days = d } } h.quickCreateUser(chatID, adminID, args[1], days) } else { h.startUserWizard(chatID, adminID) } case args[0] == "assign", args[0] == "сквад": if len(args) < 2 { h.sendText(chatID, "Укажите логин: /admin assign username") return } h.startAssignWizard(chatID, adminID, args[1]) default: h.sendAdminHelp(chatID) } } func (h *Handler) startUserWizard(chatID, adminID int64) { ctx := context.Background() data := db.WizardData{"mode": "create"} _ = h.database.SetWizard(ctx, adminID, db.StepAwaitUsername, data) h.sendText(chatID, "Создание пользователя.\n\nВведите логин (3–36 символов, a-z, 0-9, _, -):\n\n/admin cancel — отмена") } func (h *Handler) startAssignWizard(chatID, adminID int64, username string) { ctx := context.Background() data := db.WizardData{ "mode": "assign", "username": username, } _ = h.database.SetWizard(ctx, adminID, db.StepPickExternalSquad, data) h.sendExternalSquadPicker(chatID, data) } func (h *Handler) handleWizardMessage(chatID, adminID int64, text string) bool { ctx := context.Background() w, err := h.database.GetWizard(ctx, adminID) if err != nil || w == nil || w.Step == db.StepIdle || w.Step == "" { return false } switch w.Step { case db.StepAwaitUsername: if !usernameRe.MatchString(text) { h.sendText(chatID, "Неверный логин. Допустимы: a-z, 0-9, _, - (3–36 символов).") return true } w.Data.Set("username", text) _ = h.database.SetWizard(ctx, adminID, db.StepAwaitDays, w.Data) h.sendText(chatID, fmt.Sprintf("Срок подписки в днях (по умолчанию %d):", h.cfg.DefaultUserDays)) return true case db.StepAwaitDays: days := h.cfg.DefaultUserDays if text != "" { if d, err := strconv.Atoi(text); err != nil || d <= 0 { h.sendText(chatID, "Введите число дней больше 0.") return true } else { days = d } } w.Data.Set("days", days) _ = h.database.SetWizard(ctx, adminID, db.StepPickExternalSquad, w.Data) h.sendExternalSquadPicker(chatID, w.Data) return true } return false } func (h *Handler) handleWizardCallback(cq *tgbotapi.CallbackQuery) bool { if !strings.HasPrefix(cq.Data, "wz:") { return false } chatID := cq.Message.Chat.ID adminID := cq.From.ID ctx := context.Background() w, err := h.database.GetWizard(ctx, adminID) if err != nil || w == nil { return true } parts := strings.Split(cq.Data, ":") if len(parts) < 2 { return true } switch parts[1] { case "ext": if len(parts) < 3 { return true } if parts[2] == "skip" { w.Data.Set("external_squad", "") } else { w.Data.Set("external_squad", parts[2]) } _ = h.database.SetWizard(ctx, adminID, db.StepPickInternalSquads, w.Data) h.sendInternalSquadPicker(chatID, w.Data) case "int": if len(parts) < 3 { return true } if parts[2] == "done" { _ = h.database.SetWizard(ctx, adminID, db.StepConfirm, w.Data) h.sendConfirm(chatID, w.Data) return true } w.Data.ToggleUUID("internal_squads", parts[2]) _ = h.database.SetWizard(ctx, adminID, db.StepPickInternalSquads, w.Data) h.sendInternalSquadPicker(chatID, w.Data) case "ok": h.finishWizard(chatID, adminID, w.Data) case "no": _ = h.database.ClearWizard(ctx, adminID) h.sendText(chatID, "Отменено.") } return true } func (h *Handler) sendSquadsList(chatID int64) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() ext, err1 := h.panel.ListExternalSquads(ctx) ints, err2 := h.panel.ListInternalSquads(ctx) var b strings.Builder b.WriteString("Сквады Remnawave:\n\n") b.WriteString("External:\n") if err1 != nil { b.WriteString(" ошибка: " + err1.Error() + "\n") } else if len(ext) == 0 { b.WriteString(" (пусто)\n") } else { for _, s := range ext { b.WriteString(fmt.Sprintf(" • %s\n %s\n", s.Name, s.UUID)) } } b.WriteString("\nInternal:\n") if err2 != nil { b.WriteString(" ошибка: " + err2.Error() + "\n") } else if len(ints) == 0 { b.WriteString(" (пусто)\n") } else { for _, s := range ints { b.WriteString(fmt.Sprintf(" • %s\n %s\n", s.Name, s.UUID)) } } h.sendText(chatID, b.String()) } func (h *Handler) sendExternalSquadPicker(chatID int64, data db.WizardData) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() squads, err := h.panel.ListExternalSquads(ctx) if err != nil { h.sendText(chatID, "Не удалось загрузить external squads: "+err.Error()) return } var rows [][]tgbotapi.InlineKeyboardButton rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("⏭ Без external squad", "wz:ext:skip"), )) for _, s := range squads { label := s.Name if len(label) > 40 { label = label[:40] } rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData(label, "wz:ext:"+s.UUID), )) } msg := tgbotapi.NewMessage(chatID, "Выберите External Squad:") msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(rows...) h.send(msg) } func (h *Handler) sendInternalSquadPicker(chatID int64, data db.WizardData) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() squads, err := h.panel.ListInternalSquads(ctx) if err != nil { h.sendText(chatID, "Не удалось загрузить internal squads: "+err.Error()) return } selected := map[string]bool{} for _, id := range data.StringSlice("internal_squads") { selected[id] = true } var rows [][]tgbotapi.InlineKeyboardButton for _, s := range squads { mark := "☐" if selected[s.UUID] { mark = "☑" } label := fmt.Sprintf("%s %s", mark, s.Name) if len(label) > 60 { label = label[:60] } rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData(label, "wz:int:"+s.UUID), )) } rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("✅ Готово", "wz:int:done"), )) msg := tgbotapi.NewMessage(chatID, "Выберите Internal Squads (можно несколько), затем «Готово»:") msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(rows...) h.send(msg) } func (h *Handler) sendConfirm(chatID int64, data db.WizardData) { ext := data.String("external_squad") ints := data.StringSlice("internal_squads") text := fmt.Sprintf( "Подтвердите:\n\nЛогин: %s\nДней: %d\nExternal: %s\nInternal: %d шт.\n", data.String("username"), data.Int("days"), squadLabel(ext), len(ints), ) if data.String("mode") == "assign" { text = fmt.Sprintf( "Назначить сквады пользователю %s\n\nExternal: %s\nInternal: %d шт.\n", data.String("username"), squadLabel(ext), len(ints), ) } msg := tgbotapi.NewMessage(chatID, text) msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup( tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("✅ Да", "wz:ok"), tgbotapi.NewInlineKeyboardButtonData("❌ Нет", "wz:no"), ), ) h.send(msg) } func (h *Handler) finishWizard(chatID, adminID int64, data db.WizardData) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() ext := data.String("external_squad") var extPtr *string if ext != "" { extPtr = &ext } ints := data.StringSlice("internal_squads") if len(ints) == 0 && len(h.cfg.DefaultInternalSquadUUIDs) > 0 { ints = h.cfg.DefaultInternalSquadUUIDs } if extPtr == nil && h.cfg.DefaultExternalSquadUUID != "" { e := h.cfg.DefaultExternalSquadUUID extPtr = &e } mode := data.String("mode") if mode == "assign" { u, err := h.panel.AssignSquads(ctx, remnawave.AssignSquadsInput{ Username: data.String("username"), ExternalSquadUUID: extPtr, ActiveInternalSquads: ints, }) _ = h.database.ClearWizard(ctx, adminID) if err != nil { h.sendText(chatID, "Ошибка назначения сквадов: "+err.Error()) return } h.sendText(chatID, fmt.Sprintf("✅ Сквады назначены пользователю %s\nUUID: %s", u.Username, u.UUID)) return } days := data.Int("days") if days <= 0 { days = h.cfg.DefaultUserDays } var tgID *int64 // при создании из мастера админом telegramId не обязателен u, err := h.panel.CreateUser(ctx, remnawave.CreateUserInput{ Username: data.String("username"), ExpireAt: db.DefaultExpireAt(days), TelegramID: tgID, ExternalSquadUUID: extPtr, ActiveInternalSquads: ints, Description: "created via tgvpn bot", }) _ = h.database.ClearWizard(ctx, adminID) if err != nil { h.sendText(chatID, "Ошибка создания: "+err.Error()) return } vpn := db.VPNUser{ RemnawaveUUID: u.UUID, RemnawaveUsername: u.Username, ExternalSquadUUID: extPtr, InternalSquadUUIDs: ints, ExpireAt: &u.ExpireAt, } if err := h.database.SaveVPNUser(ctx, vpn); err != nil { log.Printf("save vpn user: %v", err) } text := fmt.Sprintf("✅ Пользователь создан\n\nЛогин: %s\nUUID: %s\nИстекает: %s", u.Username, u.UUID, u.ExpireAt.Format("2006-01-02")) if u.SubscriptionURL != "" { text += "\nПодписка: " + u.SubscriptionURL } else if u.ShortUUID != "" && h.cfg.RemnawaveSubscription != "" { text += "\nПодписка: " + h.cfg.RemnawaveSubscription + "/" + u.ShortUUID } h.sendText(chatID, text) } func (h *Handler) quickCreateUser(chatID, adminID int64, username string, days int) { if !usernameRe.MatchString(username) { h.sendText(chatID, "Неверный логин.") return } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() var extPtr *string if h.cfg.DefaultExternalSquadUUID != "" { e := h.cfg.DefaultExternalSquadUUID extPtr = &e } ints := h.cfg.DefaultInternalSquadUUIDs u, err := h.panel.CreateUser(ctx, remnawave.CreateUserInput{ Username: username, ExpireAt: db.DefaultExpireAt(days), ExternalSquadUUID: extPtr, ActiveInternalSquads: ints, Description: "created via tgvpn bot", }) if err != nil { h.sendText(chatID, "Ошибка: "+err.Error()) return } _ = h.database.SaveVPNUser(ctx, db.VPNUser{ RemnawaveUUID: u.UUID, RemnawaveUsername: u.Username, ExternalSquadUUID: extPtr, InternalSquadUUIDs: ints, ExpireAt: &u.ExpireAt, }) h.sendText(chatID, fmt.Sprintf("✅ %s создан до %s", u.Username, u.ExpireAt.Format("2006-01-02"))) } func squadLabel(uuid string) string { if uuid == "" { return "—" } if len(uuid) > 12 { return uuid[:8] + "…" } return uuid }