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 := "☐"