Explorar o código

fix(tgbot): unstick the add-client wizard's inbound picker (#6621)

* fix(tgbot): unstick the add-client wizard's inbound picker

Tapping "➕ Новый клиент" always sent the "choose inbound" message,
even when the button list ended up empty after protocol filtering.
getInboundsAddClient checked len(inbounds)==0 before filtering but
never re-checked after, so an admin whose every inbound was excluded
got a message with nothing to tap and no further feedback.

WireGuard and AmneziaWG were excluded outright too, a holdover from
the wizard's original 2025 implementation, before
defaultWireguardClients
and defaultAmneziaWGClients existed. Both now auto-generate a keypair +
AllowedIPs for a client with none set, and the subscription server
already emits wireguard:// and vpn:// share links for them, so both
inbound types flow through the same generic Create path as VLESS/Trojan
already used by the bot. Mixed/HTTP/Tunnel stay excluded: they have no
per-client model in this codebase.

- getInboundsAddClient now returns getInboundsFailed when the button
  list is empty after filtering, instead of sending an unusable keyboard
- WireGuard/AmneziaWG removed from the exclusion list in both
  getInboundsAddClient and getInboundsAttachPicker
- the previously duplicated excludedProtocols map is now a single
  package-level addClientExcludedProtocols shared by both functions

* test(tgbot): pin which inbounds the add-client picker offers

The picker change had no test. One drives a database holding WireGuard,
AmneziaWG, VLESS and Mixed inbounds and wants the first three offered;
the other holds only Mixed, HTTP and Tunnel and wants getInboundsFailed
instead of an empty keyboard. Both fail on the previous picker.

---------

Co-authored-by: MHSanaei <[email protected]>
Roman Chesnakov hai 8 horas
pai
achega
bd01f923fb

+ 66 - 0
internal/web/service/tgbot/tgbot_add_client_picker_test.go

@@ -0,0 +1,66 @@
+package tgbot
+
+import (
+	"fmt"
+	"path/filepath"
+	"slices"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"github.com/mymmrac/telego"
+	"github.com/nicksnyder/go-i18n/v2/i18n"
+)
+
+func seedPickerInbounds(t *testing.T, protocols ...model.Protocol) {
+	t.Helper()
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	for i, protocol := range protocols {
+		port := 20000 + i
+		ib := &model.Inbound{Remark: string(protocol), Enable: true, Port: port, Protocol: protocol, Tag: fmt.Sprintf("inbound-%d", port), Settings: `{}`}
+		if err := database.GetDB().Create(ib).Error; err != nil {
+			t.Fatalf("seed %s inbound: %v", protocol, err)
+		}
+	}
+}
+
+func pickerLabels(keyboard *telego.InlineKeyboardMarkup) []string {
+	var labels []string
+	for _, row := range keyboard.InlineKeyboard {
+		for _, button := range row {
+			labels = append(labels, button.Text)
+		}
+	}
+	slices.Sort(labels)
+	return labels
+}
+
+// WireGuard and AmneziaWG clients get a generated keypair and address on Create,
+// so the wizard offers them; Mixed authenticates per inbound and has no clients.
+func TestAddClientPickerOffersWireGuardAndAmneziaWG(t *testing.T) {
+	seedPickerInbounds(t, model.VLESS, model.WireGuard, model.AmneziaWG, model.Mixed)
+
+	keyboard, err := (&Tgbot{}).getInboundsAddClient()
+	if err != nil {
+		t.Fatalf("getInboundsAddClient: %v", err)
+	}
+	want := []string{"amneziawg - ✅", "vless - ✅", "wireguard - ✅"}
+	if got := pickerLabels(keyboard); !slices.Equal(got, want) {
+		t.Fatalf("picker buttons = %q, want %q", got, want)
+	}
+}
+
+// An empty keyboard sent the admin a "choose inbound" prompt with nothing to tap.
+func TestAddClientPickerFailsWhenNoInboundTakesClients(t *testing.T) {
+	draftLocalizer(t, &i18n.Message{ID: "tgbot.answers.getInboundsFailed", Other: "Failed to get inbounds."})
+	seedPickerInbounds(t, model.Mixed, model.HTTP, model.Tunnel)
+
+	keyboard, err := (&Tgbot{}).getInboundsAddClient()
+	if err == nil || err.Error() != "Failed to get inbounds." {
+		t.Fatalf("getInboundsAddClient = (%v, %v), want the getInboundsFailed error", keyboard, err)
+	}
+}

+ 15 - 17
internal/web/service/tgbot/tgbot_inbound.go

@@ -136,6 +136,14 @@ func (t *Tgbot) getInboundClientsFor(inbound *model.Inbound, action string) (*te
 	return keyboard, nil
 }
 
+// addClientExcludedProtocols are the protocols with no per-client model: Tunnel
+// has no clients, Mixed/HTTP authenticate at the inbound level, not per-client.
+var addClientExcludedProtocols = map[model.Protocol]bool{
+	model.Tunnel: true,
+	model.Mixed:  true,
+	model.HTTP:   true,
+}
+
 // getInboundsAddClient creates an inline keyboard for adding clients to inbounds.
 func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
 	inbounds, err := t.inboundService.GetAllInbounds()
@@ -149,17 +157,9 @@ func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
 		return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
 	}
 
-	excludedProtocols := map[model.Protocol]bool{
-		model.Tunnel:    true,
-		model.Mixed:     true,
-		model.WireGuard: true,
-		model.AmneziaWG: true,
-		model.HTTP:      true,
-	}
-
 	var buttons []telego.InlineKeyboardButton
 	for _, inbound := range inbounds {
-		if excludedProtocols[inbound.Protocol] {
+		if addClientExcludedProtocols[inbound.Protocol] {
 			continue
 		}
 
@@ -171,6 +171,11 @@ func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
 		buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
 	}
 
+	if len(buttons) == 0 {
+		logger.Warning("No inbounds eligible for add-client (all excluded by protocol)")
+		return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
+	}
+
 	cols := 1
 	if len(buttons) >= 6 {
 		cols = 2
@@ -194,20 +199,13 @@ func (t *Tgbot) getInboundsAttachPicker(draft *clientDraft) (*telego.InlineKeybo
 	if len(inbounds) == 0 {
 		return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
 	}
-	excludedProtocols := map[model.Protocol]bool{
-		model.Tunnel:    true,
-		model.Mixed:     true,
-		model.WireGuard: true,
-		model.AmneziaWG: true,
-		model.HTTP:      true,
-	}
 	selected := make(map[int]bool, len(draft.receiverInboundIDs))
 	for _, id := range draft.receiverInboundIDs {
 		selected[id] = true
 	}
 	var buttons []telego.InlineKeyboardButton
 	for _, ib := range inbounds {
-		if excludedProtocols[ib.Protocol] {
+		if addClientExcludedProtocols[ib.Protocol] {
 			continue
 		}
 		mark := "☐"