From cbb21339910ff9cd6c34870c65d3dbbf72f2ff87 Mon Sep 17 00:00:00 2001 From: tgvpn Date: Thu, 21 May 2026 01:29:55 +0300 Subject: [PATCH] Add /config trial VPN generation for users (1 day default) Users get Remnawave subscription via /config or inline button; TRIAL_USER_DAYS and panel lookup by Telegram ID. Co-authored-by: Cursor --- .env.example | 6 +- CHANGELOG.md | 1 + README.md | 12 +++- install.sh | 4 +- internal/bot/handler.go | 62 ++++++++++++---- internal/bot/user_config.go | 136 ++++++++++++++++++++++++++++++++++++ internal/config/config.go | 11 ++- internal/db/users.go | 24 +++++++ internal/remnawave/users.go | 46 ++++++++++-- 9 files changed, 278 insertions(+), 24 deletions(-) create mode 100644 internal/bot/user_config.go diff --git a/.env.example b/.env.example index 403ddae..7b17058 100644 --- a/.env.example +++ b/.env.example @@ -24,8 +24,10 @@ POSTGRES_PASSWORD=change_me_strong_password POSTGRES_DB=tgvpn DATABASE_URL=postgres://tgvpn:change_me_strong_password@db:5432/tgvpn?sslmode=disable -# Создание пользователей по умолчанию -DEFAULT_USER_DAYS=30 +# Срок подписки: для /config у пользователей бота +TRIAL_USER_DAYS=1 +# Для /admin user (создание админом) +DEFAULT_USER_DAYS=1 # UUID сквадов из панели (/admin squads), через запятую для internal DEFAULT_EXTERNAL_SQUAD_UUID= DEFAULT_INTERNAL_SQUAD_UUIDS= diff --git a/CHANGELOG.md b/CHANGELOG.md index 72fb2a9..2096663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Добавлено +- `/config` и кнопка «Получить конфиг» — trial-подписка на `TRIAL_USER_DAYS` (по умолчанию 1 день), создание пользователя в Remnawave и ссылка на подписку - `install.sh` — интерактивный установщик на Linux-сервер (опрос параметров, `.env`, Docker) - PostgreSQL 16 в Docker Compose (`DATABASE_URL`) - Создание пользователей Remnawave: `/admin user`, `/admin user <логин> [дней]` diff --git a/README.md b/README.md index 656bf32..ca58cff 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ docker compose logs --tail=30 bot docker compose logs --tail=20 db ``` -В Telegram: `/start`, от админа — `/admin squads`, `/admin user`. +В Telegram: `/start` → кнопка «Получить конфиг» или `/config` (trial на `TRIAL_USER_DAYS`, по умолчанию 1 день). От админа — `/admin squads`, `/admin user`. ### 5. Остановка @@ -551,11 +551,19 @@ go build -o bot . | `CADDY_AUTH_API_TOKEN` | нет | `X-Api-Key`, если включён Caddy with security (как в оф. `.env` subscription-page) | | `REMNAWAVE_SUBSCRIPTION_URL` | нет | Опционально: домен Subscription Page (`sub.*`), отдельная проверка | | `DATABASE_URL` | да | PostgreSQL, в compose: `postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable` | -| `DEFAULT_USER_DAYS` | нет | Срок подписки по умолчанию (30) | +| `TRIAL_USER_DAYS` | нет | Срок trial-конфига для `/config` (по умолчанию 1) | +| `DEFAULT_USER_DAYS` | нет | Срок при создании админом `/admin user` (по умолчанию 1) | | `DEFAULT_EXTERNAL_SQUAD_UUID` | нет | External squad по умолчанию при быстром создании | | `DEFAULT_INTERNAL_SQUAD_UUIDS` | нет | Internal squads через запятую | | `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) | +### Команды для пользователей + +- `/start` — приветствие и кнопка получения конфига +- `/config` — создать пользователя в Remnawave на `TRIAL_USER_DAYS` (если активная подписка уже есть — вернёт существующую ссылку) + +Нужны `DEFAULT_EXTERNAL_SQUAD_UUID` и `DEFAULT_INTERNAL_SQUAD_UUIDS` — те же сквады, что для быстрого `/admin user`. + ### Админ-меню в боте Только пользователь с `TELEGRAM_ADMIN_ID`: diff --git a/install.sh b/install.sh index 4d343cb..39c0d42 100644 --- a/install.sh +++ b/install.sh @@ -112,6 +112,7 @@ POSTGRES_PASSWORD=${POSTGRES_PASSWORD} POSTGRES_DB=${POSTGRES_DB} DATABASE_URL=${DATABASE_URL} +TRIAL_USER_DAYS=${TRIAL_USER_DAYS} DEFAULT_USER_DAYS=${DEFAULT_USER_DAYS} DEFAULT_EXTERNAL_SQUAD_UUID=${DEFAULT_EXTERNAL_SQUAD_UUID} DEFAULT_INTERNAL_SQUAD_UUIDS=${DEFAULT_INTERNAL_SQUAD_UUIDS} @@ -197,7 +198,8 @@ main() { echo "" info "=== Пользователи VPN (по умолчанию) ===" - DEFAULT_USER_DAYS="$(prompt "Срок подписки по умолчанию (дней)" "30")" + TRIAL_USER_DAYS="$(prompt "Срок trial-конфига для пользователей бота (/config), дней" "1")" + DEFAULT_USER_DAYS="$(prompt "Срок при создании админом (/admin user), дней" "1")" DEFAULT_EXTERNAL_SQUAD_UUID="$(prompt "DEFAULT_EXTERNAL_SQUAD_UUID (опционально)" "")" DEFAULT_INTERNAL_SQUAD_UUIDS="$(prompt "DEFAULT_INTERNAL_SQUAD_UUIDS через запятую (опционально)" "")" diff --git a/internal/bot/handler.go b/internal/bot/handler.go index d04c787..070d550 100644 --- a/internal/bot/handler.go +++ b/internal/bot/handler.go @@ -35,17 +35,18 @@ func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI, database *db.DB) *Hand } func (h *Handler) RegisterCommands() { - commands := []tgbotapi.BotCommand{ + public := []tgbotapi.BotCommand{ {Command: "start", Description: "Начать"}, - {Command: "admin", Description: "Админ-меню Remnawave (панель 1)"}, + {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} - cfg := tgbotapi.SetMyCommandsConfig{ - Commands: commands, - Scope: &scope, - } - if _, err := h.api.Request(cfg); err != nil { - log.Printf("не удалось зарегистрировать команды для админа: %v", err) + if _, err := h.api.Request(tgbotapi.SetMyCommandsConfig{Commands: admin, Scope: &scope}); err != nil { + log.Printf("команды (админ): %v", err) } } @@ -65,6 +66,8 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) { 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, "/"): @@ -73,6 +76,10 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) { if h.isAdmin(userID) && h.handleWizardMessage(chatID, userID, text) { return } + if text == "📲 Получить конфиг (1 день)" || strings.HasPrefix(text, "📲 Получить конфиг") { + h.handleUserConfig(chatID, userID) + return + } if h.isAdmin(userID) { switch text { case "📋 Конфиг панели": @@ -131,6 +138,11 @@ func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) { return } + if cq.Data == "user:config" { + h.handleUserConfig(cq.Message.Chat.ID, cq.From.ID) + return + } + if h.handleWizardCallback(cq) { return } @@ -163,17 +175,41 @@ func (h *Handler) sendStart(chatID, userID int64, firstName, tgUsername string) if name == "" { name = "друг" } - text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот на базе панели Remnawave.", name) + days := h.cfg.TrialUserDays + if days <= 0 { + days = 1 + } + text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот. Нажмите кнопку ниже — получите конфиг на %d дн.\nИли команда /config", name, days) if h.isAdmin(userID) { - text += "\n\n/admin — админ-меню\n/admin user — создать пользователя\n/admin squads — сквады" + text += "\n\n/admin — админ-меню" } msg := tgbotapi.NewMessage(chatID, text) - if h.isAdmin(userID) { - msg.ReplyMarkup = adminReplyKeyboard() - } + msg.ReplyMarkup = h.startInlineKeyboard(userID) h.send(msg) } +func (h *Handler) startInlineKeyboard(userID int64) tgbotapi.InlineKeyboardMarkup { + rows := [][]tgbotapi.InlineKeyboardButton{ + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(h.userConfigButtonLabel(), "user:config"), + ), + } + 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 + } + return fmt.Sprintf("📲 Получить конфиг (%d дн.)", days) +} + func (h *Handler) sendAdminMenu(chatID int64) { text := fmt.Sprintf( "🛠 *Админ-меню* — %s\n\n"+ diff --git a/internal/bot/user_config.go b/internal/bot/user_config.go new file mode 100644 index 0000000..69b2a23 --- /dev/null +++ b/internal/bot/user_config.go @@ -0,0 +1,136 @@ +package bot + +import ( + "context" + "fmt" + "log" + "time" + + "telegramvpn/internal/db" + "telegramvpn/internal/remnawave" +) + +func (h *Handler) handleUserConfig(chatID, telegramID int64) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + days := h.cfg.TrialUserDays + if days <= 0 { + days = 1 + } + + existing, err := h.database.GetVPNByTelegramID(ctx, telegramID) + if err != nil { + h.sendText(chatID, "Ошибка базы данных. Попробуйте позже.") + return + } + 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) + return + } + + panelUser, err := h.panel.GetUserByTelegramID(ctx, telegramID) + if err == nil && panelUser != nil && panelUser.ExpireAt.After(time.Now()) { + link := panelUser.SubscriptionURL + if link == "" { + link = h.subscriptionLink(panelUser.ShortUUID) + } + _ = h.saveVPNFromPanel(ctx, telegramID, panelUser) + h.sendConfigMessage(chatID, days, panelUser.Username, panelUser.ExpireAt, link) + return + } + + username := fmt.Sprintf("u%d", telegramID) + var extPtr *string + if h.cfg.DefaultExternalSquadUUID != "" { + e := h.cfg.DefaultExternalSquadUUID + extPtr = &e + } + ints := h.cfg.DefaultInternalSquadUUIDs + tgID := telegramID + + u, err := h.panel.CreateUser(ctx, remnawave.CreateUserInput{ + Username: username, + ExpireAt: db.DefaultExpireAt(days), + TelegramID: &tgID, + ExternalSquadUUID: extPtr, + ActiveInternalSquads: ints, + Description: fmt.Sprintf("trial %d day via tgvpn bot", days), + }) + if err != nil { + h.sendText(chatID, "Не удалось создать доступ: "+err.Error()+"\n\nПопробуйте позже или напишите администратору.") + return + } + + if err := h.saveVPNFromPanel(ctx, telegramID, u); err != nil { + log.Printf("save vpn user: %v", err) + } + + link := u.SubscriptionURL + if link == "" { + link = h.subscriptionLink(u.ShortUUID) + } + h.sendConfigMessage(chatID, days, u.Username, u.ExpireAt, link) +} + +func (h *Handler) saveVPNFromPanel(ctx context.Context, telegramID int64, u *remnawave.PanelUser) error { + if u == nil { + return nil + } + var ext *string + if h.cfg.DefaultExternalSquadUUID != "" { + e := h.cfg.DefaultExternalSquadUUID + ext = &e + } + return h.database.SaveVPNUser(ctx, db.VPNUser{ + TelegramID: &telegramID, + RemnawaveUUID: u.UUID, + RemnawaveUsername: u.Username, + ExternalSquadUUID: ext, + InternalSquadUUIDs: h.cfg.DefaultInternalSquadUUIDs, + ExpireAt: &u.ExpireAt, + }) +} + +func (h *Handler) resolveSubscriptionLink(ctx context.Context, username string, telegramID int64) string { + if u, err := h.panel.GetUserByUsername(ctx, username); err == nil && u != nil { + if u.SubscriptionURL != "" { + return u.SubscriptionURL + } + return h.subscriptionLink(u.ShortUUID) + } + if u, err := h.panel.GetUserByTelegramID(ctx, telegramID); err == nil && u != nil { + if u.SubscriptionURL != "" { + return u.SubscriptionURL + } + return h.subscriptionLink(u.ShortUUID) + } + return "" +} + +func (h *Handler) subscriptionLink(shortUUID string) string { + if shortUUID != "" && h.cfg.RemnawaveSubscription != "" { + return h.cfg.RemnawaveSubscription + "/" + shortUUID + } + return "" +} + +func (h *Handler) sendConfigMessage(chatID int64, days int, username string, expireAt time.Time, link string) { + text := fmt.Sprintf( + "✅ Ваш VPN-конфиг готов\n\n"+ + "Срок: %d дн. (до %s)\n"+ + "Логин: %s\n", + days, + expireAt.Local().Format("02.01.2006 15:04"), + username, + ) + if link != "" { + text += "\n🔗 Ссылка подписки (добавьте в приложение):\n" + link + } else { + text += "\n⚠️ Ссылка подписки не настроена. Администратору нужно указать REMNAWAVE_SUBSCRIPTION_URL в .env" + } + text += "\n\nИмпортируйте ссылку в V2rayNG, Hiddify, Streisand и др." + h.sendText(chatID, text) +} + diff --git a/internal/config/config.go b/internal/config/config.go index 3524a16..e3f7ab7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ type Config struct { RemnawaveSubscription string DatabaseURL string DefaultUserDays int + TrialUserDays int DefaultExternalSquadUUID string DefaultInternalSquadUUIDs []string } @@ -69,13 +70,20 @@ func Load() (*Config, error) { return nil, fmt.Errorf("DATABASE_URL не задан") } - days := 30 + days := 1 if v := strings.TrimSpace(os.Getenv("DEFAULT_USER_DAYS")); v != "" { if d, err := strconv.Atoi(v); err == nil && d > 0 { days = d } } + trialDays := days + if v := strings.TrimSpace(os.Getenv("TRIAL_USER_DAYS")); v != "" { + if d, err := strconv.Atoi(v); err == nil && d > 0 { + trialDays = d + } + } + var internalSquads []string if v := strings.TrimSpace(os.Getenv("DEFAULT_INTERNAL_SQUAD_UUIDS")); v != "" { for _, part := range strings.Split(v, ",") { @@ -97,6 +105,7 @@ func Load() (*Config, error) { RemnawaveSubscription: subURL, DatabaseURL: dbURL, DefaultUserDays: days, + TrialUserDays: trialDays, DefaultExternalSquadUUID: strings.TrimSpace(os.Getenv("DEFAULT_EXTERNAL_SQUAD_UUID")), DefaultInternalSquadUUIDs: internalSquads, }, nil diff --git a/internal/db/users.go b/internal/db/users.go index c34c833..1dabb19 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -46,6 +46,30 @@ func (d *DB) SaveVPNUser(ctx context.Context, u VPNUser) error { return err } +func (d *DB) GetVPNByTelegramID(ctx context.Context, telegramID int64) (*VPNUser, error) { + row := d.pool.QueryRow(ctx, ` + SELECT id, telegram_id, remnawave_uuid::text, remnawave_username, + external_squad_uuid::text, internal_squad_uuids::text[], expire_at + FROM vpn_users + WHERE telegram_id = $1 + ORDER BY expire_at DESC NULLS LAST + LIMIT 1`, telegramID) + + var u VPNUser + var ext *string + var internal []string + err := row.Scan(&u.ID, &u.TelegramID, &u.RemnawaveUUID, &u.RemnawaveUsername, &ext, &internal, &u.ExpireAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + u.ExternalSquadUUID = ext + u.InternalSquadUUIDs = internal + return &u, nil +} + func (d *DB) GetVPNByUsername(ctx context.Context, username string) (*VPNUser, error) { row := d.pool.QueryRow(ctx, ` SELECT id, telegram_id, remnawave_uuid::text, remnawave_username, diff --git a/internal/remnawave/users.go b/internal/remnawave/users.go index 0f72b87..15b776b 100644 --- a/internal/remnawave/users.go +++ b/internal/remnawave/users.go @@ -19,14 +19,48 @@ type CreateUserInput struct { } type PanelUser struct { - UUID string - Username string - ShortUUID string - Status string - ExpireAt time.Time + UUID string + Username string + ShortUUID string + Status string + ExpireAt time.Time SubscriptionURL string } +func (c *Client) GetUserByUsername(ctx context.Context, username string) (*PanelUser, error) { + path := fmt.Sprintf("/api/users/by-username/%s", username) + resp, body, err := c.get(ctx, path) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, apiError(resp.StatusCode, body) + } + return parsePanelUser(body), nil +} + +func (c *Client) GetUserByTelegramID(ctx context.Context, telegramID int64) (*PanelUser, error) { + path := fmt.Sprintf("/api/users/by-telegram-id/%d", telegramID) + resp, body, err := c.get(ctx, path) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, apiError(resp.StatusCode, body) + } + u := parsePanelUser(body) + if u == nil || u.UUID == "" { + return nil, nil + } + return u, nil +} + func (c *Client) CreateUser(ctx context.Context, in CreateUserInput) (*PanelUser, error) { payload := map[string]any{ "username": in.Username, @@ -118,6 +152,8 @@ func parsePanelUser(body []byte) *PanelUser { if json.Unmarshal(raw, &s) == nil { if t, err := time.Parse(time.RFC3339Nano, s); err == nil { u.ExpireAt = t + } else if t, err := time.Parse(time.RFC3339, s); err == nil { + u.ExpireAt = t } } }