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 <cursoragent@cursor.com>
This commit is contained in:
+4
-2
@@ -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=
|
||||
|
||||
@@ -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 <логин> [дней]`
|
||||
|
||||
@@ -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`:
|
||||
|
||||
+3
-1
@@ -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 через запятую (опционально)" "")"
|
||||
|
||||
|
||||
+49
-13
@@ -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"+
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,40 @@ type PanelUser struct {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user