Add PostgreSQL, user/squad management, remove private domains from docs

This commit is contained in:
tgvpn
2026-05-21 01:13:23 +03:00
parent d0dc8d5822
commit 5e3229e998
17 changed files with 1171 additions and 58 deletions
+407
View File
@@ -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, _, - (336 символов).")
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
}
+51 -15
View File
@@ -8,6 +8,7 @@ import (
"time"
"telegramvpn/internal/config"
"telegramvpn/internal/db"
"telegramvpn/internal/remnawave"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
@@ -16,18 +17,20 @@ import (
const docsURL = "https://docs.rw/"
type Handler struct {
cfg *config.Config
api *tgbotapi.BotAPI
panel *remnawave.Client
admin int64
cfg *config.Config
api *tgbotapi.BotAPI
panel *remnawave.Client
database *db.DB
admin int64
}
func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI) *Handler {
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),
admin: cfg.TelegramAdminID,
cfg: cfg,
api: api,
panel: remnawave.NewClient(cfg.RemnawavePanelURL, cfg.RemnawaveAPIToken, cfg.CaddyAuthAPIToken),
database: database,
admin: cfg.TelegramAdminID,
}
}
@@ -61,12 +64,15 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) {
switch {
case text == "/start":
h.sendStart(chatID, userID, update.Message.From.FirstName)
h.sendStart(chatID, userID, update.Message.From.FirstName, update.Message.From.UserName)
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
}
if h.isAdmin(userID) {
switch text {
case "📋 Конфиг панели":
@@ -75,6 +81,12 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) {
case "🔌 Проверить панель":
h.sendPanelCheck(chatID)
return
case "👤 Создать пользователя":
h.startUserWizard(chatID, userID)
return
case "📡 Сквады":
h.sendSquadsList(chatID)
return
case "◀️ Выйти из админки":
h.sendText(chatID, "Админ-меню закрыто. /admin — снова открыть.")
return
@@ -101,6 +113,8 @@ func (h *Handler) handleAdminCommand(chatID, userID int64, text string) {
h.sendPanelCheck(chatID)
case "config", "конфиг":
h.sendPanelConfig(chatID)
case "user", "пользователь", "squads", "сквады", "assign", "сквад", "cancel", "отмена", "help":
h.handleAdminUsersSubcommand(chatID, userID, args[2:])
default:
h.sendAdminHelp(chatID)
}
@@ -117,7 +131,15 @@ func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) {
return
}
if h.handleWizardCallback(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":
@@ -133,14 +155,17 @@ func (h *Handler) isAdmin(userID int64) bool {
return userID == h.admin
}
func (h *Handler) sendStart(chatID, userID int64, firstName string) {
func (h *Handler) sendStart(chatID, userID int64, firstName, tgUsername string) {
ctx := context.Background()
_ = h.database.UpsertTelegramUser(ctx, userID, tgUsername, firstName)
name := firstName
if name == "" {
name = "друг"
}
text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот на базе панели Remnawave.", name)
if h.isAdmin(userID) {
text += "\n\n/admin — админ-меню\n/admin check — проверка API и подписки"
text += "\n\n/admin — админ-меню\n/admin user — создать пользователя\n/admin squads — сквады"
}
msg := tgbotapi.NewMessage(chatID, text)
if h.isAdmin(userID) {
@@ -155,7 +180,10 @@ func (h *Handler) sendAdminMenu(chatID int64) {
"Команды:\n"+
"• /admin — это меню\n"+
"• /admin check — проверка панели, API и подписки\n"+
"• /admin config — конфиг панели\n\n"+
"• /admin config — конфиг панели\n"+
"• /admin user — создать пользователя\n"+
"• /admin squads — список сквадов\n"+
"• /admin assign <логин> — назначить сквады\n\n"+
"Или кнопки ниже.",
escapeMarkdown(h.cfg.RemnawaveName),
)
@@ -166,7 +194,7 @@ func (h *Handler) sendAdminMenu(chatID int64) {
}
func (h *Handler) sendAdminHelp(chatID int64) {
h.sendText(chatID, "Неизвестный аргумент.\n\n/admin — меню\n/admin check — проверка\n/admin config — конфиг")
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) {
@@ -219,7 +247,11 @@ func (h *Handler) sendPanelCheck(chatID int64) {
func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
return tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить (API+подписка)", "admin:check"),
tgbotapi.NewInlineKeyboardButtonData("👤 Создать пользователя", "admin:user"),
tgbotapi.NewInlineKeyboardButtonData("📡 Сквады", "admin:squads"),
),
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить", "admin:check"),
tgbotapi.NewInlineKeyboardButtonData("📋 Конфиг", "admin:config"),
),
tgbotapi.NewInlineKeyboardRow(
@@ -231,6 +263,10 @@ func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
func adminReplyKeyboard() tgbotapi.ReplyKeyboardMarkup {
return tgbotapi.ReplyKeyboardMarkup{
Keyboard: [][]tgbotapi.KeyboardButton{
{
tgbotapi.NewKeyboardButton("👤 Создать пользователя"),
tgbotapi.NewKeyboardButton("📡 Сквады"),
},
{
tgbotapi.NewKeyboardButton("🔌 Проверить панель"),
tgbotapi.NewKeyboardButton("📋 Конфиг панели"),