Add PostgreSQL, user/squad management, remove private domains from docs
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user