From bd01f923fb7ee16a2ad4843e59e3809dbf8c63b1 Mon Sep 17 00:00:00 2001 From: Roman Chesnakov <152219970+romus204@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:01:56 +0700 Subject: [PATCH] fix(tgbot): unstick the add-client wizard's inbound picker (#6621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../tgbot/tgbot_add_client_picker_test.go | 66 +++++++++++++++++++ internal/web/service/tgbot/tgbot_inbound.go | 32 +++++---- 2 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 internal/web/service/tgbot/tgbot_add_client_picker_test.go diff --git a/internal/web/service/tgbot/tgbot_add_client_picker_test.go b/internal/web/service/tgbot/tgbot_add_client_picker_test.go new file mode 100644 index 000000000..c1679b37e --- /dev/null +++ b/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) + } +} diff --git a/internal/web/service/tgbot/tgbot_inbound.go b/internal/web/service/tgbot/tgbot_inbound.go index 28b66ad79..a163e2698 100644 --- a/internal/web/service/tgbot/tgbot_inbound.go +++ b/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 := "☐"