From 4307f7b4b3dddba8f3fe52f4decc6f3d15c44a8b Mon Sep 17 00:00:00 2001 From: Ben Hall Date: Sun, 6 Sep 2026 17:48:16 +0100 Subject: [PATCH 1/5] feat: message performance Signed-off-by: Ben Hall --- .../endpoints/api/gallery/galleryimport.go | 7 + .../endpoints/api/panel/messagedata_test.go | 146 ++ .../endpoints/api/panel/multipanelcreate.go | 156 +- .../http/endpoints/api/panel/multipanelget.go | 8 + .../endpoints/api/panel/multipanellist.go | 27 +- .../api/panel/multipanelmessagedata.go | 474 ++++-- .../endpoints/api/panel/multipanelupdate.go | 85 +- .../http/endpoints/api/panel/panelcreate.go | 63 +- .../app/http/endpoints/api/panel/panellist.go | 11 + .../endpoints/api/panel/panelmessagedata.go | 143 +- .../http/endpoints/api/panel/panelupdate.go | 49 +- .../http/endpoints/api/panel/validation.go | 463 ++++++ .../endpoints/api/panel/validation_test.go | 1451 +++++++++++++++++ .../http/endpoints/api/user/featureflags.go | 1 + backend/go.mod | 4 +- frontend/package-lock.json | 71 + frontend/package.json | 4 + frontend/src/components/PanelPreview.tsx | 413 +++-- .../component-builder/AddBlockMenu.tsx | 197 +++ .../component-builder/AutoAppendedTag.tsx | 18 + .../component-builder/BlockList.tsx | 296 ++++ .../component-builder/BlockShell.tsx | 168 ++ .../component-builder/BuildPreviewTabs.tsx | 74 + .../component-builder/ButtonRowEditor.tsx | 181 ++ .../ComponentBudgetMeter.tsx | 29 + .../ComponentTreeBuilder.tsx | 227 +++ .../component-builder/ContainerEditor.tsx | 90 + .../component-builder/MediaGalleryEditor.tsx | 84 + .../component-builder/MessageModeToggle.tsx | 100 ++ .../component-builder/PanelButtonTag.tsx | 25 + .../component-builder/SectionEditor.tsx | 247 +++ .../component-builder/SeparatorEditor.tsx | 31 + .../component-builder/TextDisplayEditor.tsx | 20 + .../components/discord/DiscordComponents.tsx | 22 + .../discord/container/ActionRow.tsx | 2 + .../discord/container/Container.tsx | 1 + .../src/components/modal-primitives/Modal.tsx | 9 +- .../src/components/modals/ConfirmModal.tsx | 13 +- frontend/src/hooks/queries/useGuild.ts | 4 +- frontend/src/lib/api.ts | 6 +- frontend/src/lib/component-tree.ts | 1175 +++++++++++++ frontend/src/lib/discord-markdown.ts | 2 +- frontend/src/lib/feature-flags.ts | 8 + frontend/src/lib/panel-payload.ts | 18 + .../src/pages/manage/multipanels/create.tsx | 675 +++++--- .../src/pages/manage/multipanels/edit.tsx | 745 ++++++--- frontend/src/pages/manage/panels/_index.tsx | 36 +- frontend/src/pages/manage/panels/create.tsx | 931 +++++++---- frontend/src/pages/manage/panels/edit.tsx | 1031 ++++++++---- frontend/src/types.d.ts | 26 + 50 files changed, 8652 insertions(+), 1415 deletions(-) create mode 100644 backend/app/http/endpoints/api/panel/messagedata_test.go create mode 100644 backend/app/http/endpoints/api/panel/validation_test.go create mode 100644 frontend/src/components/component-builder/AddBlockMenu.tsx create mode 100644 frontend/src/components/component-builder/AutoAppendedTag.tsx create mode 100644 frontend/src/components/component-builder/BlockList.tsx create mode 100644 frontend/src/components/component-builder/BlockShell.tsx create mode 100644 frontend/src/components/component-builder/BuildPreviewTabs.tsx create mode 100644 frontend/src/components/component-builder/ButtonRowEditor.tsx create mode 100644 frontend/src/components/component-builder/ComponentBudgetMeter.tsx create mode 100644 frontend/src/components/component-builder/ComponentTreeBuilder.tsx create mode 100644 frontend/src/components/component-builder/ContainerEditor.tsx create mode 100644 frontend/src/components/component-builder/MediaGalleryEditor.tsx create mode 100644 frontend/src/components/component-builder/MessageModeToggle.tsx create mode 100644 frontend/src/components/component-builder/PanelButtonTag.tsx create mode 100644 frontend/src/components/component-builder/SectionEditor.tsx create mode 100644 frontend/src/components/component-builder/SeparatorEditor.tsx create mode 100644 frontend/src/components/component-builder/TextDisplayEditor.tsx create mode 100644 frontend/src/lib/component-tree.ts diff --git a/backend/app/http/endpoints/api/gallery/galleryimport.go b/backend/app/http/endpoints/api/gallery/galleryimport.go index aecc39d8..a509f2cf 100644 --- a/backend/app/http/endpoints/api/gallery/galleryimport.go +++ b/backend/app/http/endpoints/api/gallery/galleryimport.go @@ -111,9 +111,16 @@ func ImportHandler(ctx *gin.Context) { buttonStyle = int(*listing.ButtonStyle) } + // The gallery listing's own Name is the human-facing label the submitter gave the + // whole template (e.g. "Basic Support Panel") - a better default for the dashboard-only + // panel Name than Title, which is the Discord-facing embed heading and often reads more + // like end-user copy (e.g. "Need help?"). + panelName := listing.Name + panel := database.Panel{ ChannelId: 0, // Will be set if we send a message GuildId: guildId, + Name: &panelName, Title: listing.Title, Content: listing.Content, Colour: listing.Colour, diff --git a/backend/app/http/endpoints/api/panel/messagedata_test.go b/backend/app/http/endpoints/api/panel/messagedata_test.go new file mode 100644 index 00000000..902c4a9b --- /dev/null +++ b/backend/app/http/endpoints/api/panel/messagedata_test.go @@ -0,0 +1,146 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/TicketsBot-cloud/gdl/objects/channel/embed" + "github.com/TicketsBot-cloud/gdl/objects/channel/message" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Regression coverage for a live bug: Discord rejected an edit to an already-V2 panel +// message ("components[0]: Value of field \"type\" must be one of (1,)") because Flags +// was only set on the first Classic-to-V2 transition. EditMessageData.Flags has no +// `omitempty`, so leaving it unset on a V2-to-V2 edit sent an explicit "flags":0, and +// Discord validated the submitted Components tree against Classic (ActionRow-only) rules. + +func TestPanelMessageData_BuildEditData_FlagsAlwaysSetInV2Mode(t *testing.T) { + textDisplay := component.Component{ + Type: component.ComponentTextDisplay, + ComponentData: component.TextDisplay{Content: "hello"}, + } + + cases := []struct { + name string + wasAlreadyV2 bool + }{ + {"first classic-to-v2 transition", false}, + {"ongoing v2-to-v2 edit", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := &panelMessageData{ + UsesComponentsV2: true, + Components: []component.Component{textDisplay}, + ButtonLabel: "Open a ticket", + CustomId: "open_ticket", + } + + data := p.buildEditData(tc.wasAlreadyV2) + + require.Equal(t, message.SumFlags(message.FlagComponentsV2), data.Flags, "Flags must always be the V2 flag in V2 mode, regardless of wasAlreadyV2") + + // Guard the underlying wire-format assumption directly: Flags has no + // omitempty, so a zero value would previously have serialised as + // "flags":0 rather than being omitted. + raw, err := json.Marshal(data) + require.NoError(t, err) + + var decoded map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &decoded)) + assert.EqualValues(t, message.FlagComponentsV2, decoded["flags"], "flags must be non-zero on the wire") + + // The guild-authored tree plus the system-appended open-ticket button + // must both be present, and the top-level type must remain valid for V2. + require.Len(t, data.Components, 2) + assert.Equal(t, component.ComponentTextDisplay, data.Components[0].Type) + assert.Equal(t, component.ComponentActionRow, data.Components[1].Type) + }) + } +} + +func TestPanelMessageData_BuildEditData_ClassicModeUnaffected(t *testing.T) { + p := &panelMessageData{ + UsesComponentsV2: false, + Title: "Support", + ButtonLabel: "Open a ticket", + CustomId: "open_ticket", + } + + data := p.buildEditData(false) + + assert.Zero(t, data.Flags) + require.Len(t, data.Embeds, 1) + require.Len(t, data.Components, 1) +} + +func TestMultiPanelMessageData_BuildEditData_FlagsAlwaysSetInV2Mode(t *testing.T) { + textDisplay := component.Component{ + Type: component.ComponentTextDisplay, + ComponentData: component.TextDisplay{Content: "hello"}, + } + + systemComponents := []component.Component{ + component.BuildActionRow(component.BuildButton(component.Button{ + Label: "Support", + CustomId: "multipanel_0", + Style: component.ButtonStylePrimary, + })), + } + + cases := []struct { + name string + wasAlreadyV2 bool + }{ + {"first classic-to-v2 transition", false}, + {"ongoing v2-to-v2 edit", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := &multiPanelMessageData{ + UsesComponentsV2: true, + Components: []component.Component{textDisplay}, + } + + data := d.buildEditData(d.Components, systemComponents, tc.wasAlreadyV2) + + require.Equal(t, message.SumFlags(message.FlagComponentsV2), data.Flags, "Flags must always be the V2 flag in V2 mode, regardless of wasAlreadyV2") + + raw, err := json.Marshal(data) + require.NoError(t, err) + + var decoded map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &decoded)) + assert.EqualValues(t, message.FlagComponentsV2, decoded["flags"], "flags must be non-zero on the wire") + + require.Len(t, data.Components, 2) + assert.Equal(t, component.ComponentTextDisplay, data.Components[0].Type) + assert.Equal(t, component.ComponentActionRow, data.Components[1].Type) + }) + } +} + +func TestMultiPanelMessageData_BuildEditData_ClassicModeUnaffected(t *testing.T) { + d := &multiPanelMessageData{ + UsesComponentsV2: false, + Embed: embed.NewEmbed(), + } + + systemComponents := []component.Component{ + component.BuildActionRow(component.BuildButton(component.Button{ + Label: "Support", + CustomId: "multipanel_0", + Style: component.ButtonStylePrimary, + })), + } + + data := d.buildEditData(d.Components, systemComponents, false) + + assert.Zero(t, data.Flags) + require.Len(t, data.Components, 1) +} diff --git a/backend/app/http/endpoints/api/panel/multipanelcreate.go b/backend/app/http/endpoints/api/panel/multipanelcreate.go index d40169e4..e0ee2434 100644 --- a/backend/app/http/endpoints/api/panel/multipanelcreate.go +++ b/backend/app/http/endpoints/api/panel/multipanelcreate.go @@ -4,11 +4,15 @@ import ( "context" "errors" "fmt" + "math" "net/http" + "strings" "github.com/TicketsBot-cloud/common/featureflags" + "github.com/TicketsBot-cloud/common/premium" "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/channel" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/gdl/rest/request" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" @@ -17,6 +21,7 @@ import ( "github.com/ticketsbot-cloud/dashboard/backend/app/http/validation" "github.com/ticketsbot-cloud/dashboard/backend/botcontext" dbclient "github.com/ticketsbot-cloud/dashboard/backend/database" + "github.com/ticketsbot-cloud/dashboard/backend/rpc" "github.com/ticketsbot-cloud/dashboard/backend/rpc/cache" "github.com/ticketsbot-cloud/dashboard/backend/utils" "github.com/ticketsbot-cloud/dashboard/backend/utils/types" @@ -39,21 +44,90 @@ func getEffectiveLabelForValidation(buttonLabel string, customLabel *string) str } type multiPanelCreateData struct { - ChannelId uint64 `json:"channel_id,string"` - SelectMenu bool `json:"select_menu"` - SelectMenuPlaceholder *string `json:"select_menu_placeholder,omitempty" validate:"omitempty,max=150"` - Panels []panelConfiguration `json:"panels" validate:"dive"` - Embed *types.CustomEmbed `json:"embed" validate:"omitempty"` + Name string `json:"name"` + ChannelId uint64 `json:"channel_id,string"` + SelectMenu bool `json:"select_menu"` + SelectMenuPlaceholder *string `json:"select_menu_placeholder,omitempty" validate:"omitempty,max=150"` + Panels []panelConfiguration `json:"panels" validate:"dive"` + Embed *types.CustomEmbed `json:"embed" validate:"omitempty"` + UsesComponentsV2 bool `json:"uses_components_v2"` + Components []component.Component `json:"components"` } func (d *multiPanelCreateData) IntoMessageData(footer footerPolicy) multiPanelMessageData { - return multiPanelMessageData{ + data := multiPanelMessageData{ Footer: footer, ChannelId: d.ChannelId, SelectMenu: d.SelectMenu, SelectMenuPlaceholder: d.SelectMenuPlaceholder, - Embed: d.Embed.IntoDiscordEmbed(), + UsesComponentsV2: d.UsesComponentsV2, + Components: d.Components, } + + if !d.UsesComponentsV2 { + data.Embed = d.Embed.IntoDiscordEmbed() + } + + return data +} + +// validateMultiPanelComponents enforces the type-allowlist and reserved-budget check on +// a multi-panel's Components V2 tree. Premium gating is handled separately by the caller +// with an explicit 402, matching the free-tier quota check in panelcreate.go. +// +// A panel referenced by a placeholder inside the tree (a button carrying PanelId, or the +// dropdown carrying IsPanelSelect) is excluded from the reserved system-row budget below, +// since the backend no longer appends it to the default row - see buildSystemComponents +// in multipanelmessagedata.go. validPanelIds is built from data.Panels, which +// validatePanels has already ownership-checked against this guild earlier in the same +// request's validation pipeline (doValidations runs before this function is ever +// called), so every ID accepted here is guaranteed to belong to this guild's own panels. +func validateMultiPanelComponents(data multiPanelCreateData) error { + if !data.UsesComponentsV2 { + return nil + } + + validPanelIds := make(map[int]bool, len(data.Panels)) + for _, p := range data.Panels { + validPanelIds[p.PanelId] = true + } + + if err := validateComponentTree(data.Components, validPanelIds, data.SelectMenu); err != nil { + return err + } + + placed, hasSelect, err := collectPlacedPanelIds(data.Components) + if err != nil { + return err + } + + unplacedCount := 0 + for _, p := range data.Panels { + if !placed[p.PanelId] { + unplacedCount++ + } + } + + if hasSelect && unplacedCount == 0 { + return validation.NewInvalidInputError("The panel dropdown placed in the message must have at least one panel left to list; every panel in this multi-panel is already placed elsewhere in the message") + } + + var reservedTopLevel, reservedTotal int + if data.SelectMenu { + if !hasSelect { + // 1 row + 1 select menu, covering every panel not already placed elsewhere via + // a panel button. A placed dropdown reserves nothing extra here: it is already + // counted as part of the authored tree by countComponents (row + select = the + // same 1 top-level / 2 total this reservation exists to cover). + reservedTopLevel, reservedTotal = 1, 2 + } + } else { + n := unplacedCount + rows := int(math.Ceil(float64(n) / 5)) + reservedTopLevel, reservedTotal = rows, rows+n + } + + return validateComponentTreeBudget(data.Components, reservedTopLevel, reservedTotal) } func MultiPanelCreate(c *gin.Context) { @@ -71,6 +145,15 @@ func MultiPanelCreate(c *gin.Context) { return } + if data.UsesComponentsV2 { + // The classic embed is never read for a Components V2 message (see IntoMessageData + // and the persistence branch below, both gated on !UsesComponentsV2) - the frontend + // still submits whatever embed state it has regardless of message mode, so validating + // its contents here would reject a Components V2 multi-panel over fields that are + // never sent to Discord or stored. + data.Embed = nil + } + if err := validate.Struct(data); err != nil { var validationErrors validator.ValidationErrors if ok := errors.As(err, &validationErrors); !ok { @@ -83,6 +166,16 @@ func MultiPanelCreate(c *gin.Context) { return } + if err := validateResourceName(data.Name, "Multi-panel"); err != nil { + var validationError *validation.InvalidInputError + if errors.As(err, &validationError) { + c.JSON(400, utils.ErrorStr("%s", validationError.Error())) + } else { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to create multi-panel")) + } + return + } + // validate body & get sub-panels panels, err := data.doValidations(guildId) if err != nil { @@ -123,6 +216,27 @@ func MultiPanelCreate(c *gin.Context) { return } + premiumTier, err := rpc.PremiumClient.GetTierByGuildId(c, guildId, false, botContext.Token, botContext.RateLimiter) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to verify premium status")) + return + } + + if data.UsesComponentsV2 && premiumTier == premium.None { + c.JSON(402, utils.ErrorStr("Component-based multi-panel messages require premium. Purchase premium to unlock this feature.")) + return + } + + if err := validateMultiPanelComponents(data); err != nil { + var validationError *validation.InvalidInputError + if errors.As(err, &validationError) { + c.JSON(400, utils.ErrorStr("%s", validationError.Error())) + } else { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to create multi-panel")) + } + return + } + footer, err := footerPolicyForGuild(c, guildId, botContext) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to create multi-panel")) @@ -158,17 +272,35 @@ func MultiPanelCreate(c *gin.Context) { return } - dbEmbed, dbEmbedFields := data.Embed.IntoDatabaseStruct() + var dbEmbedWithFields *database.CustomEmbedWithFields + var componentsJSON *string + if data.UsesComponentsV2 { + componentsJSON, err = marshalComponents(data.Components) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to create multi-panel")) + return + } + } else { + dbEmbed, dbEmbedFields := data.Embed.IntoDatabaseStruct() + dbEmbedWithFields = &database.CustomEmbedWithFields{ + CustomEmbed: dbEmbed, + Fields: dbEmbedFields, + } + } + + // Validated non-empty by validateResourceName; trimmed here for consistent storage. + name := strings.TrimSpace(data.Name) + multiPanel := database.MultiPanel{ MessageId: messageId, ChannelId: data.ChannelId, GuildId: guildId, SelectMenu: data.SelectMenu, SelectMenuPlaceholder: data.SelectMenuPlaceholder, - Embed: &database.CustomEmbedWithFields{ - CustomEmbed: dbEmbed, - Fields: dbEmbedFields, - }, + Embed: dbEmbedWithFields, + UsesComponentsV2: data.UsesComponentsV2, + Components: componentsJSON, + Name: &name, } multiPanel.Id, err = dbclient.Client.MultiPanels.Create(c, multiPanel) diff --git a/backend/app/http/endpoints/api/panel/multipanelget.go b/backend/app/http/endpoints/api/panel/multipanelget.go index 30128c0b..cb8c97a4 100644 --- a/backend/app/http/endpoints/api/panel/multipanelget.go +++ b/backend/app/http/endpoints/api/panel/multipanelget.go @@ -43,6 +43,7 @@ func MultiPanelGet(ctx *gin.Context) { type multiPanelResponse struct { Id int `json:"id"` + Name *string `json:"name"` MessageId uint64 `json:"message_id,string"` ChannelId uint64 `json:"channel_id,string"` GuildId uint64 `json:"guild_id,string"` @@ -50,6 +51,9 @@ func MultiPanelGet(ctx *gin.Context) { SelectMenuPlaceholder *string `json:"select_menu_placeholder"` Embed *embedResponse `json:"embed"` Panels []panelConfiguration `json:"panels"` + UsesComponentsV2 bool `json:"uses_components_v2"` + Components *string `json:"components"` + ForceDisabled bool `json:"force_disabled"` } guildId := ctx.Keys["guildid"].(uint64) @@ -117,6 +121,7 @@ func MultiPanelGet(ctx *gin.Context) { ctx.JSON(200, gin.H{ "data": multiPanelResponse{ Id: multiPanel.Id, + Name: multiPanel.Name, MessageId: multiPanel.MessageId, ChannelId: multiPanel.ChannelId, GuildId: multiPanel.GuildId, @@ -124,6 +129,9 @@ func MultiPanelGet(ctx *gin.Context) { SelectMenuPlaceholder: multiPanel.SelectMenuPlaceholder, Embed: transformedEmbed, Panels: configs, + UsesComponentsV2: multiPanel.UsesComponentsV2, + Components: multiPanel.Components, + ForceDisabled: multiPanel.ForceDisabled, }, }) } diff --git a/backend/app/http/endpoints/api/panel/multipanellist.go b/backend/app/http/endpoints/api/panel/multipanellist.go index a5572570..20fca20f 100644 --- a/backend/app/http/endpoints/api/panel/multipanellist.go +++ b/backend/app/http/endpoints/api/panel/multipanellist.go @@ -8,8 +8,10 @@ import ( func MultiPanelList(ctx *gin.Context) { type multiPanelResponse struct { - Id int `json:"id"` - Title *string `json:"title"` + Id int `json:"id"` + Name string `json:"name"` + Title *string `json:"title"` + ForceDisabled bool `json:"force_disabled"` } guildId := ctx.Keys["guildid"].(uint64) @@ -25,11 +27,28 @@ func MultiPanelList(ctx *gin.Context) { var title *string if multiPanel.Embed != nil { title = multiPanel.Embed.Title + } else if multiPanel.UsesComponentsV2 { + // A Components V2 message has no embed title to fall back on. + t := "Components V2 message" + title = &t + } + + // Name is the dashboard-only label and takes priority; Title is the classic + // embed title, which doubled as the list label before Name existed, and + // still does for any multi-panel created before this field was introduced. + displayName := "Untitled multi-panel" + if title != nil && *title != "" { + displayName = *title + } + if multiPanel.Name != nil && *multiPanel.Name != "" { + displayName = *multiPanel.Name } data[i] = multiPanelResponse{ - Id: multiPanel.Id, - Title: title, + Id: multiPanel.Id, + Name: displayName, + Title: title, + ForceDisabled: multiPanel.ForceDisabled, } } diff --git a/backend/app/http/endpoints/api/panel/multipanelmessagedata.go b/backend/app/http/endpoints/api/panel/multipanelmessagedata.go index 0cff3522..73de2133 100644 --- a/backend/app/http/endpoints/api/panel/multipanelmessagedata.go +++ b/backend/app/http/endpoints/api/panel/multipanelmessagedata.go @@ -2,17 +2,21 @@ package api import ( "context" + "encoding/json" "fmt" "math" "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/channel/embed" + "github.com/TicketsBot-cloud/gdl/objects/channel/message" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/gdl/rest" "github.com/TicketsBot-cloud/gdl/utils" "github.com/ticketsbot-cloud/dashboard/backend/botcontext" "github.com/ticketsbot-cloud/dashboard/backend/config" + "github.com/ticketsbot-cloud/dashboard/backend/log" "github.com/ticketsbot-cloud/dashboard/backend/utils/types" + "go.uber.org/zap" ) type multiPanelMessageData struct { @@ -24,6 +28,11 @@ type multiPanelMessageData struct { SelectMenuPlaceholder *string Embed *embed.Embed + + // UsesComponentsV2 switches send()/edit() from the classic embed builder to the + // Components V2 tree in Components, with the system select-menu/button-rows appended. + UsesComponentsV2 bool + Components []component.Component } func (d *multiPanelMessageData) applyFooter() { @@ -43,15 +52,32 @@ func multiPanelDiscordSubPanelError(action, detail string) string { } func multiPanelIntoMessageData(panel database.MultiPanel, footer footerPolicy) multiPanelMessageData { - return multiPanelMessageData{ + data := multiPanelMessageData{ Footer: footer, ChannelId: panel.ChannelId, SelectMenu: panel.SelectMenu, SelectMenuPlaceholder: panel.SelectMenuPlaceholder, - Embed: types.NewCustomEmbed(panel.Embed.CustomEmbed, panel.Embed.Fields).IntoDiscordEmbed(), } + + if panel.UsesComponentsV2 && panel.Components != nil { + var components []component.Component + if err := json.Unmarshal([]byte(*panel.Components), &components); err != nil { + // Data was validated and marshalled by us on write, so this should not happen. + // Fall back to the classic embed rather than sending a broken/empty message. + log.Logger.Error("Failed to unmarshal multi-panel components; falling back to classic message", zap.Int("multi_panel_id", panel.Id), zap.Error(err)) + } else { + data.UsesComponentsV2 = true + data.Components = components + } + } + + if !data.UsesComponentsV2 && panel.Embed != nil { + data.Embed = types.NewCustomEmbed(panel.Embed.CustomEmbed, panel.Embed.Fields).IntoDiscordEmbed() + } + + return data } func getEffectiveLabel(panel database.Panel, customLabel *string) string { @@ -92,25 +118,96 @@ func getEffectiveEmojiAnimated(panel database.Panel, customEmojiName *string, cu return panel.EmojiAnimated } -func (d *multiPanelMessageData) send(ctx *botcontext.BotContext, panels []database.PanelWithCustomization) (uint64, error) { - d.applyFooter() +// effectiveDescription treats a non-nil pointer to an empty string as "no description". +// The dashboard always submits this field as a string (never omitting it), so an admin who +// leaves a panel's description blank still produces a non-nil *string pointing at "". Discord +// rejects a select option whose description field is present but empty, so this must never be +// passed through as-is. +func effectiveDescription(description *string) *string { + if description != nil && *description == "" { + return nil + } + return description +} - var components []component.Component - if d.SelectMenu { - options := make([]component.SelectOption, len(panels)) - for i, pwc := range panels { - effectiveEmojiName := getEffectiveEmoji(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiId := getEffectiveEmojiId(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiAnimated := getEffectiveEmojiAnimated(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - emoji := types.NewEmoji(effectiveEmojiName, effectiveEmojiId, effectiveEmojiAnimated).IntoGdl() - - options[i] = component.SelectOption{ - Label: getEffectiveLabel(pwc.Panel, pwc.CustomLabel), - Value: pwc.CustomId, - Description: pwc.Description, - Emoji: emoji, - } +// buildPanelSelectOptions builds one SelectOption per panel, shared between the +// system-owned dropdown built here and a panel-select placeholder resolved inside a +// guild-authored tree (see resolveComponentTreePanelRefs). +func buildPanelSelectOptions(panels []database.PanelWithCustomization) []component.SelectOption { + options := make([]component.SelectOption, len(panels)) + for i, pwc := range panels { + effectiveEmojiName := getEffectiveEmoji(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + effectiveEmojiId := getEffectiveEmojiId(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + effectiveEmojiAnimated := getEffectiveEmojiAnimated(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + emoji := types.NewEmoji(effectiveEmojiName, effectiveEmojiId, effectiveEmojiAnimated).IntoGdl() + + options[i] = component.SelectOption{ + Label: getEffectiveLabel(pwc.Panel, pwc.CustomLabel), + Value: pwc.CustomId, + Description: effectiveDescription(pwc.Description), + Emoji: emoji, } + } + + return options +} + +// unplacedPanels filters panels down to those not already referenced by a panel +// placeholder somewhere in d.Components, so buildSystemComponents never duplicates a +// panel that has been placed inside the guild-authored tree. Derived directly from +// d.Components (a validated tree - already carrying panel_id/is_panel_select for a +// Components V2 multi-panel, whether just bound from a request or reconstructed from +// stored JSON by multiPanelIntoMessageData) rather than threaded in as a parameter, so +// every call path - initial send, in-place edit, and the resend/panel-update/panel-delete +// flows that rebuild multiPanelMessageData from stored data - stays correct without each +// caller having to recompute placement itself. +func (d *multiPanelMessageData) unplacedPanels(panels []database.PanelWithCustomization) []database.PanelWithCustomization { + if !d.UsesComponentsV2 || len(d.Components) == 0 { + return panels + } + + placed, hasSelect, err := collectPlacedPanelIds(d.Components) + if err != nil { + // A tree that fails this walk should never reach here - it was already validated + // at save time. Fail safe by treating nothing as placed, rather than dropping + // every panel from the default row on an unexpected error. + return panels + } + + // A placed panel-select node has no PanelId-bearing entries of its own in placed + // (only Buttons carry a PanelId), but it still absorbs every remaining panel - its + // options are built from the unplaced subset when resolved, see + // resolveComponentPanelRef - so the default row has nothing left to show at all. + if hasSelect { + return nil + } + + if len(placed) == 0 { + return panels + } + + unplaced := make([]database.PanelWithCustomization, 0, len(panels)) + for _, pwc := range panels { + if !placed[pwc.PanelId] { + unplaced = append(unplaced, pwc) + } + } + + return unplaced +} + +// buildSystemComponents builds the backend-owned select-menu or button-rows that are +// always appended on top of whatever the guild authors, in both Classic and Components +// V2 mode. Any panel already placed inside the guild-authored tree (see unplacedPanels) +// is excluded here, so it never appears twice. +func (d *multiPanelMessageData) buildSystemComponents(panels []database.PanelWithCustomization) []component.Component { + panels = d.unplacedPanels(panels) + if len(panels) == 0 { + return nil + } + + if d.SelectMenu { + options := buildPanelSelectOptions(panels) var placeholder string if d.SelectMenuPlaceholder == nil { @@ -119,7 +216,7 @@ func (d *multiPanelMessageData) send(ctx *botcontext.BotContext, panels []databa placeholder = *d.SelectMenuPlaceholder } - components = []component.Component{ + return []component.Component{ component.BuildActionRow( component.BuildSelectMenu( component.SelectMenu{ @@ -132,46 +229,63 @@ func (d *multiPanelMessageData) send(ctx *botcontext.BotContext, panels []databa }), ), } - } else { - buttons := make([]component.Component, len(panels)) - for i, pwc := range panels { - effectiveEmojiName := getEffectiveEmoji(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiId := getEffectiveEmojiId(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiAnimated := getEffectiveEmojiAnimated(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - emoji := types.NewEmoji(effectiveEmojiName, effectiveEmojiId, effectiveEmojiAnimated).IntoGdl() - - buttons[i] = component.BuildButton(component.Button{ - Label: getEffectiveLabel(pwc.Panel, pwc.CustomLabel), - CustomId: pwc.CustomId, - Style: component.ButtonStyle(pwc.ButtonStyle), - Emoji: emoji, - Disabled: pwc.Disabled, - }) - } + } - var rows []component.Component - for i := 0; i <= int(math.Ceil(float64(len(buttons)/5))); i++ { - lb := i * 5 - ub := lb + 5 + buttons := make([]component.Component, len(panels)) + for i, pwc := range panels { + effectiveEmojiName := getEffectiveEmoji(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + effectiveEmojiId := getEffectiveEmojiId(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + effectiveEmojiAnimated := getEffectiveEmojiAnimated(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + emoji := types.NewEmoji(effectiveEmojiName, effectiveEmojiId, effectiveEmojiAnimated).IntoGdl() + + buttons[i] = component.BuildButton(component.Button{ + Label: getEffectiveLabel(pwc.Panel, pwc.CustomLabel), + CustomId: pwc.CustomId, + Style: component.ButtonStyle(pwc.ButtonStyle), + Emoji: emoji, + Disabled: pwc.Disabled, + }) + } - if ub >= len(buttons) { - ub = len(buttons) - } + var rows []component.Component + for i := 0; i <= int(math.Ceil(float64(len(buttons))/5)); i++ { + lb := i * 5 + ub := lb + 5 - if lb >= ub { - break - } + if ub >= len(buttons) { + ub = len(buttons) + } - row := component.BuildActionRow(buttons[lb:ub]...) - rows = append(rows, row) + if lb >= ub { + break } - components = rows + row := component.BuildActionRow(buttons[lb:ub]...) + rows = append(rows, row) } - data := rest.CreateMessageData{ - Embeds: []*embed.Embed{d.Embed}, - Components: components, + return rows +} + +func (d *multiPanelMessageData) send(ctx *botcontext.BotContext, panels []database.PanelWithCustomization) (uint64, error) { + // buildSystemComponents must run before the tree is resolved: it filters out panels + // placed inside the tree by reading d.Components (see unplacedPanels), and resolving + // the tree clears the panel_id/is_panel_select markers it depends on. + systemComponents := d.buildSystemComponents(panels) + + var data rest.CreateMessageData + if d.UsesComponentsV2 { + resolvedComponents := resolveComponentTreePanelRefs(d.Components, panels) + data = rest.CreateMessageData{ + Flags: message.SumFlags(message.FlagComponentsV2), + Components: append(append([]component.Component{}, resolvedComponents...), systemComponents...), + } + } else { + d.applyFooter() + data = rest.CreateMessageData{ + Embeds: []*embed.Embed{d.Embed}, + Components: systemComponents, + } } // TODO: Use proper context @@ -183,88 +297,212 @@ func (d *multiPanelMessageData) send(ctx *botcontext.BotContext, panels []databa return msg.Id, nil } -func (d *multiPanelMessageData) edit(ctx *botcontext.BotContext, messageId uint64, panels []database.PanelWithCustomization) error { +// buildEditData constructs the outgoing edit payload. wasAlreadyV2 must reflect whether the +// message already carried the Components V2 flag before this edit - see the equivalent +// note on panelMessageData.buildEditData for why this matters. resolvedComponents must be +// d.Components already run through resolveComponentTreePanelRefs - see the call in edit(). +func (d *multiPanelMessageData) buildEditData(resolvedComponents, systemComponents []component.Component, wasAlreadyV2 bool) rest.EditMessageData { + if d.UsesComponentsV2 { + // Flags must always be set explicitly here, even on an ongoing V2-to-V2 edit - + // see the identical note on panelMessageData.buildEditData for why (EditMessageData.Flags + // has no `omitempty` in the pinned gdl version, so an unset Flags sends an + // explicit "flags":0 and Discord validates Components against Classic rules). + data := rest.EditMessageData{ + Flags: message.SumFlags(message.FlagComponentsV2), + Components: append(append([]component.Component{}, resolvedComponents...), systemComponents...), + } + + if !wasAlreadyV2 { + data.Content = "" + data.Embeds = []*embed.Embed{} + } + + return data + } + d.applyFooter() + return rest.EditMessageData{ + Embeds: []*embed.Embed{d.Embed}, + Components: systemComponents, + } +} - var components []component.Component - if d.SelectMenu { - options := make([]component.SelectOption, len(panels)) - for i, pwc := range panels { - effectiveEmojiName := getEffectiveEmoji(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiId := getEffectiveEmojiId(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiAnimated := getEffectiveEmojiAnimated(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - emoji := types.NewEmoji(effectiveEmojiName, effectiveEmojiId, effectiveEmojiAnimated).IntoGdl() - - options[i] = component.SelectOption{ - Label: getEffectiveLabel(pwc.Panel, pwc.CustomLabel), - Value: pwc.CustomId, - Description: pwc.Description, - Emoji: emoji, - } +// edit updates the existing multi-panel message. +func (d *multiPanelMessageData) edit(ctx *botcontext.BotContext, messageId uint64, panels []database.PanelWithCustomization, wasAlreadyV2 bool) error { + // buildSystemComponents must run before the tree is resolved - see the identical note + // in send(). + systemComponents := d.buildSystemComponents(panels) + resolvedComponents := resolveComponentTreePanelRefs(d.Components, panels) + data := d.buildEditData(resolvedComponents, systemComponents, wasAlreadyV2) + + _, err := rest.EditMessage(context.Background(), ctx.Token, ctx.RateLimiter, d.ChannelId, messageId, data) + return err +} + +// resolveComponentTreePanelRefs walks a guild-authored Components V2 tree and replaces +// every panel placeholder - a Button carrying PanelId, or the single SelectMenu carrying +// IsPanelSelect - with a real component built from the referenced panel(s), clearing the +// placeholder markers on the output. A node that doesn't reference a panel is returned +// unchanged. +// +// This must never be the value written back to storage: whatever the caller persists +// must remain the pre-resolve tree, still carrying panel_id/is_panel_select, so a saved +// multi-panel doesn't lose the "this opens panel X" placement information the next time +// it's edited in the dashboard. marshalComponents in multipanelcreate.go/ +// multipanelupdate.go always marshals data.Components (the request body) for that +// reason, never this function's return value. +// +// A placeholder whose panel is no longer present in panels - for example the referenced +// panel was deleted from the guild via a path outside this multi-panel's own create/ +// update flow, such as paneldelete.go rebuilding every multi-panel that targeted it - is +// dropped rather than sent to Discord half-formed: a button with no custom_id and no URL +// is not a valid Discord component, and a Section can't be sent without its accessory. +func resolveComponentTreePanelRefs(tree []component.Component, panels []database.PanelWithCustomization) []component.Component { + placed, _, err := collectPlacedPanelIds(tree) + if err != nil { + // A tree that fails this walk should never reach here - it was already validated + // at save time. Fail safe by treating nothing as placed. + placed = nil + } + + unplaced := make([]database.PanelWithCustomization, 0, len(panels)) + for _, pwc := range panels { + if !placed[pwc.PanelId] { + unplaced = append(unplaced, pwc) } + } - var placeholder string - if d.SelectMenuPlaceholder == nil { - placeholder = "Select a topic..." - } else { - placeholder = *d.SelectMenuPlaceholder + return resolveComponentSlice(tree, panels, unplaced) +} + +func resolveComponentSlice(tree []component.Component, panels, unplaced []database.PanelWithCustomization) []component.Component { + resolved := make([]component.Component, 0, len(tree)) + + for _, c := range tree { + if rc, ok := resolveComponentPanelRef(c, panels, unplaced); ok { + resolved = append(resolved, rc) } + } - components = []component.Component{ - component.BuildActionRow( - component.BuildSelectMenu( - component.SelectMenu{ - CustomId: "multipanel", - Options: options, - Placeholder: placeholder, - MinValues: utils.IntPtr(1), - MaxValues: utils.IntPtr(1), - Disabled: false, - }), - ), + return resolved +} + +// resolveComponentPanelRef resolves a single node. The bool return reports whether the +// node should be kept by its parent - false for a placeholder whose panel no longer +// exists, so the caller can drop it instead of emitting an invalid payload. +func resolveComponentPanelRef(c component.Component, panels, unplaced []database.PanelWithCustomization) (component.Component, bool) { + switch c.Type { + case component.ComponentContainer: + container, ok := c.ComponentData.(component.Container) + if !ok { + return c, true } - } else { - buttons := make([]component.Component, len(panels)) - for i, pwc := range panels { - effectiveEmojiName := getEffectiveEmoji(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiId := getEffectiveEmojiId(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiAnimated := getEffectiveEmojiAnimated(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - emoji := types.NewEmoji(effectiveEmojiName, effectiveEmojiId, effectiveEmojiAnimated).IntoGdl() - - buttons[i] = component.BuildButton(component.Button{ - Label: getEffectiveLabel(pwc.Panel, pwc.CustomLabel), - CustomId: pwc.CustomId, - Style: component.ButtonStyle(pwc.ButtonStyle), - Emoji: emoji, - Disabled: pwc.Disabled, - }) + + container.Components = resolveComponentSlice(container.Components, panels, unplaced) + c.ComponentData = container + return c, true + case component.ComponentSection: + section, ok := c.ComponentData.(component.Section) + if !ok { + return c, true } - var rows []component.Component - for i := 0; i <= int(math.Ceil(float64(len(buttons)/5))); i++ { - lb := i * 5 - ub := lb + 5 + section.Components = resolveComponentSlice(section.Components, panels, unplaced) - if ub >= len(buttons) { - ub = len(buttons) - } + resolvedAccessory, ok := resolveComponentPanelRef(section.Accessory, panels, unplaced) + if !ok { + // The accessory referenced a panel that no longer exists: a section can't be + // sent without one, so drop the whole section. + return component.Component{}, false + } - if lb >= ub { - break - } + section.Accessory = resolvedAccessory + c.ComponentData = section + return c, true + case component.ComponentActionRow: + row, ok := c.ComponentData.(component.ActionRow) + if !ok { + return c, true + } - row := component.BuildActionRow(buttons[lb:ub]...) - rows = append(rows, row) + row.Components = resolveComponentSlice(row.Components, panels, unplaced) + if len(row.Components) == 0 { + // Every button in this row referenced a panel that no longer exists. + return component.Component{}, false } - components = rows + c.ComponentData = row + return c, true + case component.ComponentButton: + button, ok := c.ComponentData.(component.Button) + if !ok || button.PanelId == nil { + return c, true + } + + pwc := findPanelById(panels, *button.PanelId) + if pwc == nil { + return component.Component{}, false + } + + return component.BuildButton(buildResolvedPanelButton(*pwc)), true + case component.ComponentSelectMenu: + sm, ok := c.ComponentData.(component.SelectMenu) + if !ok || !sm.IsPanelSelect { + return c, true + } + + if len(unplaced) == 0 { + // Every panel this dropdown would have listed is gone (for example the last + // unplaced panel was deleted from the guild via paneldelete.go after this + // placement was saved). Discord rejects a select menu with zero options, so + // drop the node rather than send a broken payload; the parent action row then + // drops too via its own len(row.Components) == 0 check. + return component.Component{}, false + } + + return component.BuildSelectMenu(component.SelectMenu{ + CustomId: "multipanel", + Options: buildPanelSelectOptions(unplaced), + Placeholder: sm.Placeholder, + MinValues: utils.IntPtr(1), + MaxValues: utils.IntPtr(1), + Disabled: false, + }), true + default: + return c, true } +} - data := rest.EditMessageData{ - Embeds: []*embed.Embed{d.Embed}, - Components: components, +// findPanelById looks a panel up by its real panel ID rather than slice position: +// panels is index-aligned with the request body in the handlers that build it, but +// resolveComponentTreePanelRefs walks the guild-authored tree in tree order, not panel +// order. +func findPanelById(panels []database.PanelWithCustomization, panelId int) *database.PanelWithCustomization { + for i := range panels { + if panels[i].PanelId == panelId { + return &panels[i] + } } - _, err := rest.EditMessage(context.Background(), ctx.Token, ctx.RateLimiter, d.ChannelId, messageId, data) - return err + return nil +} + +// buildResolvedPanelButton rebuilds a full Button from a panel's own resolved defaults - +// the same Label/Emoji/Style/Disabled buildSystemComponents would use for this panel in +// the default row - discarding anything else the client sent on the placeholder node. +// PanelId is left unset on the output, so it never leaks into the outbound Discord +// payload. +func buildResolvedPanelButton(pwc database.PanelWithCustomization) component.Button { + effectiveEmojiName := getEffectiveEmoji(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + effectiveEmojiId := getEffectiveEmojiId(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + effectiveEmojiAnimated := getEffectiveEmojiAnimated(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + emoji := types.NewEmoji(effectiveEmojiName, effectiveEmojiId, effectiveEmojiAnimated).IntoGdl() + + return component.Button{ + Label: getEffectiveLabel(pwc.Panel, pwc.CustomLabel), + CustomId: pwc.CustomId, + Style: component.ButtonStyle(pwc.ButtonStyle), + Emoji: emoji, + Disabled: pwc.Disabled, + } } diff --git a/backend/app/http/endpoints/api/panel/multipanelupdate.go b/backend/app/http/endpoints/api/panel/multipanelupdate.go index 0baf7e9f..84d7d74e 100644 --- a/backend/app/http/endpoints/api/panel/multipanelupdate.go +++ b/backend/app/http/endpoints/api/panel/multipanelupdate.go @@ -6,8 +6,10 @@ import ( "fmt" "net/http" "strconv" + "strings" "github.com/TicketsBot-cloud/common/featureflags" + "github.com/TicketsBot-cloud/common/premium" "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/rest" "github.com/TicketsBot-cloud/gdl/rest/request" @@ -18,6 +20,7 @@ import ( "github.com/ticketsbot-cloud/dashboard/backend/app/http/validation" "github.com/ticketsbot-cloud/dashboard/backend/botcontext" dbclient "github.com/ticketsbot-cloud/dashboard/backend/database" + "github.com/ticketsbot-cloud/dashboard/backend/rpc" "github.com/ticketsbot-cloud/dashboard/backend/utils" "golang.org/x/sync/errgroup" ) @@ -64,6 +67,20 @@ func MultiPanelUpdate(c *gin.Context) { return } + if multiPanel.ForceDisabled { + c.JSON(400, utils.ErrorStr("This multi-panel is disabled and cannot be modified: please reactivate premium to re-enable it")) + return + } + + if data.UsesComponentsV2 { + // The classic embed is never read for a Components V2 message (see the persistence + // branch below, gated on !UsesComponentsV2) - the frontend still submits whatever + // embed state it has regardless of message mode, so validating its contents here + // would reject a Components V2 multi-panel over fields that are never sent to + // Discord or stored. + data.Embed = nil + } + if err := validate.Struct(data); err != nil { var validationErrors validator.ValidationErrors if ok := errors.As(err, &validationErrors); !ok { @@ -76,6 +93,16 @@ func MultiPanelUpdate(c *gin.Context) { return } + if err := validateResourceName(data.Name, "Multi-panel"); err != nil { + var validationError *validation.InvalidInputError + if errors.As(err, &validationError) { + c.JSON(400, utils.ErrorStr("%s", validationError.Error())) + } else { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update multi-panel")) + } + return + } + // validate body & get sub-panels panels, err := data.doValidations(guildId) if err != nil { @@ -136,6 +163,27 @@ func MultiPanelUpdate(c *gin.Context) { return } + premiumTier, err := rpc.PremiumClient.GetTierByGuildId(c, guildId, true, botContext.Token, botContext.RateLimiter) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to verify premium status")) + return + } + + if data.UsesComponentsV2 && premiumTier == premium.None { + c.JSON(402, utils.ErrorStr("Component-based multi-panel messages require premium. Purchase premium to unlock this feature.")) + return + } + + if err := validateMultiPanelComponents(data); err != nil { + var validationError *validation.InvalidInputError + if errors.As(err, &validationError) { + c.JSON(400, utils.ErrorStr("%s", validationError.Error())) + } else { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update multi-panel")) + } + return + } + footer, err := footerPolicyForGuild(c, guildId, botContext) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update multi-panel")) @@ -157,8 +205,12 @@ func MultiPanelUpdate(c *gin.Context) { messageData := data.IntoMessageData(footer) var messageId uint64 + // Discord does not allow unsetting IS_COMPONENTS_V2 via edit, so a downgrade back to + // Classic must resend rather than edit in place, same as a channel change. + downgradingFromV2 := multiPanel.UsesComponentsV2 && !data.UsesComponentsV2 + // Check if channel changed - if multiPanel.ChannelId != data.ChannelId { + if multiPanel.ChannelId != data.ChannelId || downgradingFromV2 { ctx, cancel := app.DefaultContext() defer cancel() @@ -188,7 +240,7 @@ func MultiPanelUpdate(c *gin.Context) { } } else { // Try to edit existing message - err = messageData.edit(botContext, multiPanel.MessageId, panelsWithCustom) + err = messageData.edit(botContext, multiPanel.MessageId, panelsWithCustom, multiPanel.UsesComponentsV2) if err != nil { var unwrapped request.RestError // Gone (404/10008), or authored by a different bot and so uneditable (50005): @@ -229,7 +281,25 @@ func MultiPanelUpdate(c *gin.Context) { } // update DB - dbEmbed, dbEmbedFields := data.Embed.IntoDatabaseStruct() + var dbEmbedWithFields *database.CustomEmbedWithFields + var componentsJSON *string + if data.UsesComponentsV2 { + componentsJSON, err = marshalComponents(data.Components) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update multi-panel")) + return + } + } else { + dbEmbed, dbEmbedFields := data.Embed.IntoDatabaseStruct() + dbEmbedWithFields = &database.CustomEmbedWithFields{ + CustomEmbed: dbEmbed, + Fields: dbEmbedFields, + } + } + + // Validated non-empty by validateResourceName; trimmed here for consistent storage. + name := strings.TrimSpace(data.Name) + updated := database.MultiPanel{ Id: multiPanel.Id, MessageId: messageId, @@ -237,10 +307,11 @@ func MultiPanelUpdate(c *gin.Context) { GuildId: guildId, SelectMenu: data.SelectMenu, SelectMenuPlaceholder: data.SelectMenuPlaceholder, - Embed: &database.CustomEmbedWithFields{ - CustomEmbed: dbEmbed, - Fields: dbEmbedFields, - }, + Embed: dbEmbedWithFields, + UsesComponentsV2: data.UsesComponentsV2, + Components: componentsJSON, + ForceDisabled: multiPanel.ForceDisabled, + Name: &name, } if err = dbclient.Client.MultiPanels.Update(c, multiPanel.Id, updated); err != nil { diff --git a/backend/app/http/endpoints/api/panel/panelcreate.go b/backend/app/http/endpoints/api/panel/panelcreate.go index 79959ca9..33ab9835 100644 --- a/backend/app/http/endpoints/api/panel/panelcreate.go +++ b/backend/app/http/endpoints/api/panel/panelcreate.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "strconv" + "strings" "time" "github.com/TicketsBot-cloud/common/featureflags" @@ -38,6 +39,7 @@ type PanelAutoCloseBody struct { type panelBody struct { ChannelId uint64 `json:"channel_id,string"` MessageId uint64 `json:"message_id,string"` + Name string `json:"name"` Title string `json:"title"` Content string `json:"content"` Colour uint32 `json:"colour"` @@ -78,22 +80,29 @@ type panelBody struct { SupportCanView bool `json:"support_can_view"` SupportCanType bool `json:"support_can_type"` AutoClose PanelAutoCloseBody `json:"auto_close"` + + MessageUsesComponentsV2 bool `json:"message_uses_components_v2"` + MessageComponents []component.Component `json:"message_components"` + WelcomeMessageUsesComponentsV2 bool `json:"welcome_message_uses_components_v2"` + WelcomeMessageComponents []component.Component `json:"welcome_message_components"` } func (p *panelBody) IntoPanelMessageData(customId string, showBranding bool) panelMessageData { return panelMessageData{ - ChannelId: p.ChannelId, - Title: p.Title, - Content: p.Content, - CustomId: customId, - Colour: int(p.Colour), - ImageUrl: p.ImageUrl, - ThumbnailUrl: p.ThumbnailUrl, - Emoji: p.getEmoji(), - ButtonStyle: p.ButtonStyle, - ButtonLabel: p.ButtonLabel, - ButtonDisabled: p.Disabled, - ShowBranding: showBranding, + ChannelId: p.ChannelId, + Title: p.Title, + Content: p.Content, + CustomId: customId, + Colour: int(p.Colour), + ImageUrl: p.ImageUrl, + ThumbnailUrl: p.ThumbnailUrl, + Emoji: p.getEmoji(), + ButtonStyle: p.ButtonStyle, + ButtonLabel: p.ButtonLabel, + ButtonDisabled: p.Disabled, + ShowBranding: showBranding, + UsesComponentsV2: p.MessageUsesComponentsV2, + Components: p.MessageComponents, } } @@ -257,9 +266,17 @@ func CreatePanel(c *gin.Context) { } } - // Store welcome message embed first + // Store welcome message: exactly one of the relational embed row or the + // Components V2 JSONB column is ever populated for a given panel. var welcomeMessageEmbed *int - if data.WelcomeMessage != nil { + var welcomeMessageComponents *string + if data.WelcomeMessageUsesComponentsV2 { + welcomeMessageComponents, err = marshalComponents(data.WelcomeMessageComponents) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to save welcome message components to database")) + return + } + } else if data.WelcomeMessage != nil { embed, fields := data.WelcomeMessage.IntoDatabaseStruct() embed.GuildId = guildId @@ -272,16 +289,29 @@ func CreatePanel(c *gin.Context) { welcomeMessageEmbed = &id } + var messageComponents *string + if data.MessageUsesComponentsV2 { + messageComponents, err = marshalComponents(data.MessageComponents) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to save panel message components to database")) + return + } + } + // If ticket limit is 0, treat it as use global setting if data.TicketLimit != nil && *data.TicketLimit == 0 { data.TicketLimit = nil } + // Validated non-empty by validateName; trimmed here for consistent storage. + name := strings.TrimSpace(data.Name) + // Store in DB panel := database.Panel{ MessageId: msgId, ChannelId: data.ChannelId, GuildId: guildId, + Name: &name, Title: data.Title, Content: data.Content, Colour: int32(data.Colour), @@ -320,6 +350,11 @@ func CreatePanel(c *gin.Context) { FeedbackEnabled: data.FeedbackEnabled, SupportCanView: data.SupportCanView, SupportCanType: data.SupportCanType, + + MessageUsesComponentsV2: data.MessageUsesComponentsV2, + MessageComponents: messageComponents, + WelcomeMessageUsesComponentsV2: data.WelcomeMessageUsesComponentsV2, + WelcomeMessageComponents: welcomeMessageComponents, } createOptions := panelCreateOptions{ diff --git a/backend/app/http/endpoints/api/panel/panellist.go b/backend/app/http/endpoints/api/panel/panellist.go index 20d43996..1f0361ed 100644 --- a/backend/app/http/endpoints/api/panel/panellist.go +++ b/backend/app/http/endpoints/api/panel/panellist.go @@ -17,6 +17,7 @@ func ListPanels(c *gin.Context) { type panelResponse struct { PanelId int `json:"panel_id"` ChannelId uint64 `json:"channel_id,string"` + Name string `json:"name"` Title string `json:"title"` Colour int32 `json:"colour"` ButtonLabel string `json:"button_label"` @@ -110,9 +111,19 @@ func ListPanels(c *gin.Context) { wrapped := make([]panelResponse, len(panels)) for i, p := range panels { hasSH := supportHoursSet[p.PanelId] + + // Name is the dashboard-only label and takes priority; Title is the classic + // embed title, which doubled as the list label before Name existed, and + // still does for any panel created before this field was introduced. + displayName := p.Title + if p.Name != nil && *p.Name != "" { + displayName = *p.Name + } + wrapped[i] = panelResponse{ PanelId: p.PanelId, ChannelId: p.ChannelId, + Name: displayName, Title: p.Title, Colour: p.Colour, ButtonLabel: p.ButtonLabel, diff --git a/backend/app/http/endpoints/api/panel/panelmessagedata.go b/backend/app/http/endpoints/api/panel/panelmessagedata.go index 16c4829d..b574a96f 100644 --- a/backend/app/http/endpoints/api/panel/panelmessagedata.go +++ b/backend/app/http/endpoints/api/panel/panelmessagedata.go @@ -1,17 +1,21 @@ package api import ( + "encoding/json" "fmt" "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects" "github.com/TicketsBot-cloud/gdl/objects/channel/embed" + "github.com/TicketsBot-cloud/gdl/objects/channel/message" "github.com/TicketsBot-cloud/gdl/objects/guild/emoji" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/gdl/rest" "github.com/ticketsbot-cloud/dashboard/backend/app" "github.com/ticketsbot-cloud/dashboard/backend/botcontext" "github.com/ticketsbot-cloud/dashboard/backend/config" + "github.com/ticketsbot-cloud/dashboard/backend/log" + "go.uber.org/zap" ) type panelMessageData struct { @@ -25,6 +29,27 @@ type panelMessageData struct { ButtonLabel string ButtonDisabled bool ShowBranding bool + + // UsesComponentsV2 switches send()/edit() from the classic embed builder to the + // Components V2 tree in Components, with the system open-ticket button appended. + UsesComponentsV2 bool + Components []component.Component +} + +// marshalComponents serialises a guild-authored component tree for JSONB storage. +// An empty/nil tree is stored as NULL rather than "[]", matching the nullable column. +func marshalComponents(components []component.Component) (*string, error) { + if len(components) == 0 { + return nil, nil + } + + raw, err := json.Marshal(components) + if err != nil { + return nil, err + } + + s := string(raw) + return &s, nil } func panelIntoMessageData(panel database.Panel, showBranding bool) panelMessageData { @@ -42,7 +67,7 @@ func panelIntoMessageData(panel database.Panel, showBranding bool) panelMessageD } } - return panelMessageData{ + data := panelMessageData{ ChannelId: panel.ChannelId, Title: panel.Title, Content: panel.Content, @@ -56,9 +81,23 @@ func panelIntoMessageData(panel database.Panel, showBranding bool) panelMessageD ButtonDisabled: panel.Disabled, ShowBranding: showBranding, } + + if panel.MessageUsesComponentsV2 && panel.MessageComponents != nil { + var components []component.Component + if err := json.Unmarshal([]byte(*panel.MessageComponents), &components); err != nil { + // Data was validated and marshalled by us on write, so this should not happen. + // Fall back to the classic embed rather than sending a broken/empty message. + log.Logger.Error("Failed to unmarshal panel message components; falling back to classic message", zap.Int("panel_id", panel.PanelId), zap.Error(err)) + } else { + data.UsesComponentsV2 = true + data.Components = components + } + } + + return data } -func (p *panelMessageData) send(c *botcontext.BotContext) (uint64, error) { +func (p *panelMessageData) buildEmbed() *embed.Embed { e := embed.NewEmbed(). SetTitle(p.Title). SetDescription(p.Content). @@ -76,23 +115,40 @@ func (p *panelMessageData) send(c *botcontext.BotContext) (uint64, error) { e.SetFooter(fmt.Sprintf("Powered by %s", config.Conf.Bot.PoweredBy), config.Conf.Bot.IconUrl) } - data := rest.CreateMessageData{ - Embeds: []*embed.Embed{e}, - Components: []component.Component{ - component.BuildActionRow(component.BuildButton(component.Button{ - Label: p.ButtonLabel, - CustomId: p.CustomId, - Style: p.ButtonStyle, - Emoji: p.Emoji, - Url: nil, - Disabled: p.ButtonDisabled, - })), - }, - } + return e +} +// systemButton is the open-ticket button the backend always appends, in both +// Classic and Components V2 mode. The guild-authored tree can never contain one. +func (p *panelMessageData) systemButton() component.Component { + return component.BuildActionRow(component.BuildButton(component.Button{ + Label: p.ButtonLabel, + CustomId: p.CustomId, + Style: p.ButtonStyle, + Emoji: p.Emoji, + Url: nil, + Disabled: p.ButtonDisabled, + })) +} + +func (p *panelMessageData) send(c *botcontext.BotContext) (uint64, error) { ctx, cancel := app.DefaultContext() defer cancel() + var data rest.CreateMessageData + + if p.UsesComponentsV2 { + data = rest.CreateMessageData{ + Flags: message.SumFlags(message.FlagComponentsV2), + Components: append(append([]component.Component{}, p.Components...), p.systemButton()), + } + } else { + data = rest.CreateMessageData{ + Embeds: []*embed.Embed{p.buildEmbed()}, + Components: []component.Component{p.systemButton()}, + } + } + msg, err := rest.CreateMessage(ctx, c.Token, c.RateLimiter, p.ChannelId, data) if err != nil { return 0, err @@ -101,41 +157,46 @@ func (p *panelMessageData) send(c *botcontext.BotContext) (uint64, error) { return msg.Id, nil } -func (p *panelMessageData) edit(c *botcontext.BotContext, messageId uint64) error { - e := embed.NewEmbed(). - SetTitle(p.Title). - SetDescription(p.Content). - SetColor(p.Colour) - - if p.ImageUrl != nil { - e.SetImage(*p.ImageUrl) - } +// buildEditData constructs the outgoing edit payload. wasAlreadyV2 must reflect +// whether the message already carried the Components V2 flag before this edit: +// Discord's flag is sticky and cannot be unset via edit (panelupdate.go resends +// instead whenever downgrading back to Classic), but the first Classic-to-V2 +// transition must clear content/embeds and apply the flag in the same request. +func (p *panelMessageData) buildEditData(wasAlreadyV2 bool) rest.EditMessageData { + if p.UsesComponentsV2 { + // Flags must always be set explicitly here, even on an ongoing V2-to-V2 edit: + // EditMessageData.Flags has no `omitempty` in the pinned gdl version, so leaving + // it unset would send an explicit "flags":0, which Discord reads as "this message + // has no flags" and validates Components against Classic (ActionRow-only) rules - + // producing a components[0] "must be one of (1,)" error despite the flag being + // sticky server-side. Content/Embeds are safe to leave conditional below, since + // their "correct" steady-state value in V2 mode is already empty either way. + data := rest.EditMessageData{ + Flags: message.SumFlags(message.FlagComponentsV2), + Components: append(append([]component.Component{}, p.Components...), p.systemButton()), + } - if p.ThumbnailUrl != nil { - e.SetThumbnail(*p.ThumbnailUrl) - } + if !wasAlreadyV2 { + data.Content = "" + data.Embeds = []*embed.Embed{} + } - if p.ShowBranding { - e.SetFooter(fmt.Sprintf("Powered by %s", config.Conf.Bot.PoweredBy), config.Conf.Bot.IconUrl) + return data } - data := rest.EditMessageData{ - Embeds: []*embed.Embed{e}, - Components: []component.Component{ - component.BuildActionRow(component.BuildButton(component.Button{ - Label: p.ButtonLabel, - CustomId: p.CustomId, - Style: p.ButtonStyle, - Emoji: p.Emoji, - Url: nil, - Disabled: p.ButtonDisabled, - })), - }, + return rest.EditMessageData{ + Embeds: []*embed.Embed{p.buildEmbed()}, + Components: []component.Component{p.systemButton()}, } +} +// edit updates the existing panel message. +func (p *panelMessageData) edit(c *botcontext.BotContext, messageId uint64, wasAlreadyV2 bool) error { ctx, cancel := app.DefaultContext() defer cancel() + data := p.buildEditData(wasAlreadyV2) + _, err := rest.EditMessage(ctx, c.Token, c.RateLimiter, p.ChannelId, messageId, data) return err } diff --git a/backend/app/http/endpoints/api/panel/panelupdate.go b/backend/app/http/endpoints/api/panel/panelupdate.go index d21e5c4a..8c59c994 100644 --- a/backend/app/http/endpoints/api/panel/panelupdate.go +++ b/backend/app/http/endpoints/api/panel/panelupdate.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "github.com/TicketsBot-cloud/common/featureflags" "github.com/TicketsBot-cloud/common/premium" @@ -169,8 +170,12 @@ func UpdatePanel(c *gin.Context) { messageData := data.IntoPanelMessageData(existing.CustomId, footer.ShowBranding) var newMessageId uint64 + // Discord does not allow unsetting IS_COMPONENTS_V2 via edit, so a downgrade back to + // Classic must resend rather than edit in place (AC6), same as a channel change. + downgradingFromV2 := existing.MessageUsesComponentsV2 && !data.MessageUsesComponentsV2 + // Check if channel changed - if existing.ChannelId != data.ChannelId { + if existing.ChannelId != data.ChannelId || downgradingFromV2 { _ = rest.DeleteMessage(c, botContext.Token, botContext.RateLimiter, existing.ChannelId, existing.MessageId) newMessageId, err = messageData.send(botContext) if err != nil { @@ -193,7 +198,7 @@ func UpdatePanel(c *gin.Context) { } } else { // Try to edit existing message - err = messageData.edit(botContext, existing.MessageId) + err = messageData.edit(botContext, existing.MessageId, existing.MessageUsesComponentsV2) if err != nil { var unwrapped request.RestError // Message is gone (404/10008), or was authored by a different bot — e.g. the @@ -224,9 +229,25 @@ func UpdatePanel(c *gin.Context) { } } - // Update welcome message + // Update welcome message: exactly one of the relational embed row or the + // Components V2 JSONB column is ever populated for a given panel. var welcomeMessageEmbed *int - if data.WelcomeMessage == nil { + var welcomeMessageComponents *string + if data.WelcomeMessageUsesComponentsV2 { + // Moving to (or staying in) V2: drop any orphaned relational embed row. + if existing.WelcomeMessageEmbed != nil { + if err := dbclient.Client.Embeds.Delete(c, *existing.WelcomeMessageEmbed); err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + return + } + } + + welcomeMessageComponents, err = marshalComponents(data.WelcomeMessageComponents) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + return + } + } else if data.WelcomeMessage == nil { if existing.WelcomeMessageEmbed != nil { // If welcome message wasn't null, but now is, delete the embed if err := dbclient.Client.Embeds.Delete(c, *existing.WelcomeMessageEmbed); err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) @@ -260,17 +281,30 @@ func UpdatePanel(c *gin.Context) { } } + var messageComponents *string + if data.MessageUsesComponentsV2 { + messageComponents, err = marshalComponents(data.MessageComponents) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + return + } + } + // If ticket limit is 0, treat it as use global setting if data.TicketLimit != nil && *data.TicketLimit == 0 { data.TicketLimit = nil } + // Validated non-empty by validateName; trimmed here for consistent storage. + name := strings.TrimSpace(data.Name) + // Store in DB panel := database.Panel{ PanelId: panelId, MessageId: newMessageId, ChannelId: data.ChannelId, GuildId: guildId, + Name: &name, Title: data.Title, Content: data.Content, Colour: int32(data.Colour), @@ -309,6 +343,11 @@ func UpdatePanel(c *gin.Context) { FeedbackEnabled: data.FeedbackEnabled, SupportCanView: data.SupportCanView, SupportCanType: data.SupportCanType, + + MessageUsesComponentsV2: data.MessageUsesComponentsV2, + MessageComponents: messageComponents, + WelcomeMessageUsesComponentsV2: data.WelcomeMessageUsesComponentsV2, + WelcomeMessageComponents: welcomeMessageComponents, } // insert mention data @@ -411,7 +450,7 @@ func UpdatePanel(c *gin.Context) { // Try to edit message first var messageId uint64 - err = messageData.edit(botContext, multiPanel.MessageId, panels) + err = messageData.edit(botContext, multiPanel.MessageId, panels, multiPanel.UsesComponentsV2) if err != nil { var unwrapped request.RestError // Gone (404/10008), or authored by a different bot and so uneditable (50005): diff --git a/backend/app/http/endpoints/api/panel/validation.go b/backend/app/http/endpoints/api/panel/validation.go index 8086046e..70e22471 100644 --- a/backend/app/http/endpoints/api/panel/validation.go +++ b/backend/app/http/endpoints/api/panel/validation.go @@ -47,6 +47,14 @@ const ( MentionBehaviourDelete = "delete" ) +// panelNameMaxLength bounds the dashboard-only Panel Name label. No exact precedent +// exists for this specific field: the closest comparisons are the Discord-facing +// panel Title/ButtonLabel (80 chars, validateTitle/validateButtonLabel) and other +// dashboard-only labels elsewhere in this codebase (team names and integration names +// both cap at 32). 100 sits comfortably above the Discord-facing title cap without +// being excessive for a purely organisational label. +const panelNameMaxLength = 100 + type PanelValidationContext struct { Data panelBody GuildId uint64 @@ -65,6 +73,7 @@ func ValidatePanelBody(validationContext PanelValidationContext) error { func panelValidators() []validation.Validator[PanelValidationContext] { return []validation.Validator[PanelValidationContext]{ + validateName, validateTitle, validateContent, validateChannelId, @@ -91,9 +100,39 @@ func panelValidators() []validation.Validator[PanelValidationContext] { validateSupportCanType, validateAutoClose, validateMentionBehaviour, + validateMessageComponents, + validateWelcomeMessageComponents, + } +} + +// validateName gates the dashboard-only Panel Name label - always visible regardless +// of Classic/Components V2 mode, and never sent to Discord. Unlike Title/Content +// (which fall back to a default when blank, see ApplyPanelDefaults), Name is required: +// there is no sensible default for a label whose entire purpose is to identify this +// specific panel in the dashboard's panel list. +func validateName(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + return validateResourceName(ctx.Data.Name, "Panel") } } +// validateResourceName is the shared check behind the dashboard-only Name label on +// both panels and multi-panels: required, no sensible default (see validateName), +// bounded by the same panelNameMaxLength used for the panel-facing field. +func validateResourceName(rawName, resourceLabel string) error { + name := strings.TrimSpace(rawName) + + if name == "" { + return validation.NewInvalidInputErrorf("%s name is required", resourceLabel) + } + + if utf8.RuneCountInString(name) > panelNameMaxLength { + return validation.NewInvalidInputErrorf("%s name must be %d characters or fewer", resourceLabel, panelNameMaxLength) + } + + return nil +} + func validateTitle(ctx PanelValidationContext) validation.ValidationFunc { return func() error { if utf8.RuneCountInString(ctx.Data.Title) > 80 { @@ -584,6 +623,430 @@ func validateMentionBehaviour(ctx PanelValidationContext) validation.ValidationF } } +// validateMessageComponents gates the panel's button-message Components V2 tree. +func validateMessageComponents(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + if !ctx.Data.MessageUsesComponentsV2 { + return nil + } + + if !ctx.IsPremium { + return validation.NewInvalidInputError("Component-based messages are a premium feature") + } + + // A single panel's own message has no concept of "other panels" to place, so + // panel-placement is never valid here: no valid panel IDs, dropdown mode off. + if err := validateComponentTree(ctx.Data.MessageComponents, nil, false); err != nil { + return err + } + + // Reserve 1 top-level slot and 2 total components for the system-appended + // open-ticket button action row. + return validateComponentTreeBudget(ctx.Data.MessageComponents, 1, 2) + } +} + +// validateWelcomeMessageComponents gates the panel's welcome-message Components V2 tree. +func validateWelcomeMessageComponents(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + if !ctx.Data.WelcomeMessageUsesComponentsV2 { + return nil + } + + if !ctx.IsPremium { + return validation.NewInvalidInputError("Component-based welcome messages are a premium feature") + } + + // The welcome message has no concept of "other panels" to place either. + if err := validateComponentTree(ctx.Data.WelcomeMessageComponents, nil, false); err != nil { + return err + } + + visibleButtons := 0 + if !ctx.Data.HideCloseButton { + visibleButtons++ + } + if !ctx.Data.HideCloseWithReasonButton { + visibleButtons++ + } + if !ctx.Data.HideClaimButton { + visibleButtons++ + } + + // Reserve a flat budget of 1 top-level slot / 3 total components for form-answer + // content: the worker appends this at ticket-open time once the submitted form is + // known, so its true size can't be computed here. This is a best-effort save-time + // guard, not the sole enforcement point - the worker re-checks the hard ceiling + // when it actually builds the message and truncates if the real form exceeds it. + reservedTopLevel := 1 + reservedTotal := 3 + + if visibleButtons > 0 { + reservedTopLevel++ // the close/claim buttons' action row + reservedTotal += visibleButtons + 1 // the buttons themselves, plus their row + } + + return validateComponentTreeBudget(ctx.Data.WelcomeMessageComponents, reservedTopLevel, reservedTotal) + } +} + +// maxComponentTreeDepth bounds recursion in validateComponentTree independently of the +// top-level/total budget checks, which only run after the tree has already been fully +// walked. Discord's real component nesting is at most 2 levels deep (Container -> +// Section -> leaf), so this is generous headroom, not a semantic limit - its only job is +// to stop a pathologically deep (but otherwise structurally cheap) attacker-supplied tree +// from forcing an expensive/unbounded recursive walk before any size limit is enforced. +const maxComponentTreeDepth = 10 + +// validateComponentTree recursively rejects any interactive component (action rows, +// buttons, every select-menu/input/form-field type) at any nesting depth, and rejects +// a Section accessory that isn't a Thumbnail. System-owned interactive elements are +// appended by the backend separately and can never appear in a guild-authored tree. +// +// The two deliberate exceptions - a button placed as a stand-in for a real panel's +// "Open Ticket" button, and a select menu placed as a stand-in for the multi-panel's +// dropdown - are gated by validPanelIds and selectMenuModeOn. Pass nil/false from any +// caller that has no concept of "other panels" to place (a single panel's own +// button/welcome message): that leaves this identical to the pre-existing behaviour. +func validateComponentTree(tree []component.Component, validPanelIds map[int]bool, selectMenuModeOn bool) error { + return validateComponentTreeAtDepth(tree, 0, validPanelIds, selectMenuModeOn) +} + +func validateComponentTreeAtDepth(tree []component.Component, depth int, validPanelIds map[int]bool, selectMenuModeOn bool) error { + if depth > maxComponentTreeDepth { + return validation.NewInvalidInputError("Component tree is nested too deeply") + } + + for _, c := range tree { + if err := validateComponentNode(c, depth, validPanelIds, selectMenuModeOn); err != nil { + return err + } + } + + return nil +} + +func validateComponentNode(c component.Component, depth int, validPanelIds map[int]bool, selectMenuModeOn bool) error { + switch c.Type { + case component.ComponentContainer: + container, ok := c.ComponentData.(component.Container) + if !ok { + return validation.NewInvalidInputError("Invalid container component") + } + + return validateComponentTreeAtDepth(container.Components, depth+1, validPanelIds, selectMenuModeOn) + case component.ComponentSection: + section, ok := c.ComponentData.(component.Section) + if !ok { + return validation.NewInvalidInputError("Invalid section component") + } + + if err := validateComponentTreeAtDepth(section.Components, depth+1, validPanelIds, selectMenuModeOn); err != nil { + return err + } + + return validateSectionAccessory(section.Accessory, validPanelIds) + case component.ComponentTextDisplay, component.ComponentMediaGallery, component.ComponentFile, component.ComponentSeparator: + return nil + case component.ComponentThumbnail: + // Only valid as a section's accessory, never as a standalone tree entry. + return validation.NewInvalidInputError("A thumbnail can only be used as a section's accessory") + case component.ComponentActionRow: + row, ok := c.ComponentData.(component.ActionRow) + if !ok { + return validation.NewInvalidInputError("Invalid action row component") + } + + return validateActionRow(row, validPanelIds, selectMenuModeOn) + case component.ComponentButton, component.ComponentSelectMenu, component.ComponentInputText, + component.ComponentUserSelect, component.ComponentRoleSelect, component.ComponentMentionableSelect, component.ComponentChannelSelect, + component.ComponentLabel, component.ComponentFileUpload, component.ComponentRadioGroup, component.ComponentCheckboxGroup, component.ComponentCheckbox: + // A bare button/select outside of an action row is never valid on its own - + // Discord's schema requires both a button and a select menu live inside an action + // row. The only places a button may appear at all are a guild-authored action row + // of link/panel buttons (see validateActionRow) or a section's accessory (see + // validateSectionAccessory); the only place a select menu may appear at all is as + // the sole child of a guild-authored action row, and only as a panel-select + // placeholder (see validateActionRow/isPanelSelect) - every other select menu + // remains banned everywhere, with no exception. + return validation.NewInvalidInputError("Interactive components are not allowed in this message") + default: + return validation.NewInvalidInputErrorf("Unsupported component type %d", c.Type) + } +} + +// isLinkButton reports whether c is a link-style button with no custom_id: Discord +// handles a link button entirely client-side - clicking it just opens the URL, no +// interaction event is ever sent to the bot - so there's no custom_id for an attacker +// to spoof a system button with, and no interaction payload for the bot to mis-dispatch. +// Shared by validateActionRow and validateSectionAccessory, two of the three positions +// where a button is ever allowed in a guild-authored tree. +func isLinkButton(c component.Component) bool { + if c.Type != component.ComponentButton { + return false + } + + button, ok := c.ComponentData.(component.Button) + if !ok { + return false + } + + return button.Style == component.ButtonStyleLink && button.CustomId == "" && button.Url != nil && *button.Url != "" +} + +// isPanelButton reports whether c is a placeholder button standing in for a real panel's +// "Open Ticket" button that belongs to this multi-panel: a Button with no custom_id, no +// URL, PanelId set, and that PanelId present in validPanelIds. +// +// This is the load-bearing security rule for the whole panel-placement feature: a +// component carrying both a non-empty CustomId and a non-nil PanelId is rejected here +// (CustomId != "" fails the check below) rather than accepted under either +// interpretation - never let a client smuggle an arbitrary custom_id in alongside a +// panel_id. The only custom_id this feature ever causes to reach Discord is one the +// backend itself reads from panels.custom_id for a panel_id that genuinely belongs to +// this multi-panel (validPanelIds is built from data.Panels, which validatePanels has +// already ownership-checked against the guild before this function ever runs) - never a +// client-supplied value. +func isPanelButton(c component.Component, validPanelIds map[int]bool) bool { + if c.Type != component.ComponentButton { + return false + } + + button, ok := c.ComponentData.(component.Button) + if !ok { + return false + } + + if button.CustomId != "" || button.Url != nil || button.PanelId == nil { + return false + } + + return validPanelIds[*button.PanelId] +} + +// isPanelSelect reports whether c is a placeholder select menu marking where the +// multi-panel's dropdown should be placed inside the guild-authored tree: a SelectMenu +// with IsPanelSelect set, no custom_id, and no options (both are populated by the +// backend at resolve time - see resolveComponentTreePanelRefs). Only valid at all when +// selectMenuModeOn is true - dropdown-mode placement makes no sense when this +// multi-panel isn't actually in dropdown mode. +func isPanelSelect(c component.Component, selectMenuModeOn bool) bool { + if !selectMenuModeOn { + return false + } + + if c.Type != component.ComponentSelectMenu { + return false + } + + sm, ok := c.ComponentData.(component.SelectMenu) + if !ok { + return false + } + + return sm.IsPanelSelect && sm.CustomId == "" && len(sm.Options) == 0 +} + +// validateActionRow allows a guild-authored ActionRow - at top level or inside a +// Container's Components, never as a Section's accessory, which stays limited to a +// single Thumbnail, link button, or panel button - in one of two shapes: every child is +// a link button (see isLinkButton) or a panel button (see isPanelButton), or the row's +// sole child is the panel-select placeholder (see isPanelSelect). The second shape +// mirrors Discord's own rule that a select menu is only ever valid as the sole component +// of its action row, and matches how buildSystemComponents always wraps its own select +// menu in a single-child row. A row of several link/panel buttons is just N instances of +// the same two safe primitives; any other shape (a non-link/non-panel button, any other +// select menu type, or a select menu mixed with anything else) keeps the whole row +// banned, matching the "no interactive components" rule everywhere else. +func validateActionRow(row component.ActionRow, validPanelIds map[int]bool, selectMenuModeOn bool) error { + if len(row.Components) == 0 { + return validation.NewInvalidInputError("An action row must contain at least one component") + } + + if len(row.Components) > 5 { + return validation.NewInvalidInputError("An action row cannot contain more than 5 components") + } + + if len(row.Components) == 1 && isPanelSelect(row.Components[0], selectMenuModeOn) { + return nil + } + + for _, child := range row.Components { + if !isLinkButton(child) && !isPanelButton(child, validPanelIds) { + return validation.NewInvalidInputError("An action row may only contain link buttons or panel buttons, or a single panel dropdown on its own") + } + } + + return nil +} + +// validateSectionAccessory allows a section's accessory to be a Thumbnail, a link-style +// Button (style Link, no CustomId, has a Url), or a panel button (see isPanelButton). +// Discord handles a link button entirely client-side - clicking it just opens the URL, no +// interaction event is ever sent to the bot - so there's no custom_id for an attacker +// to spoof a system button with, and no interaction payload for the bot to mis-dispatch. +// A panel button carries no client-supplied custom_id either (see isPanelButton). Every +// other button style (has a CustomId, triggers a bot-handled interaction) and every +// select-menu type remain fully disallowed everywhere in the tree, including here. +// +// Discord's Section object requires accessory (it is not an optional field), and +// gdl's Component.MarshalJSON errors out on a zero-value ComponentData rather than +// omitting the key, so a missing accessory must be rejected here at validation time - +// otherwise it passes validation but then fails to marshal when the message is sent. +func validateSectionAccessory(accessory component.Component, validPanelIds map[int]bool) error { + if accessory.ComponentData == nil { + return validation.NewInvalidInputError("A section must have an accessory (a thumbnail, a link button, or a panel button)") + } + + switch accessory.Type { + case component.ComponentThumbnail: + return nil + case component.ComponentButton: + if !isLinkButton(accessory) && !isPanelButton(accessory, validPanelIds) { + return validation.NewInvalidInputError("A button used as a section's accessory must be a link button or a panel button, with no custom ID") + } + + return nil + default: + return validation.NewInvalidInputError("A section's accessory must be a thumbnail, a link button, or a panel button") + } +} + +// collectPlacedPanelIds walks an already validateComponentTree-approved tree +// depth-first and collects every panel that has been placed inside it, either as a +// button (PanelId) or as the single dropdown placeholder (IsPanelSelect). +// +// Whether a placed panel_id is actually a member of this multi-panel was already +// enforced by isPanelButton during validateComponentTree, so this function can assume +// every PanelId it sees has already passed that check - it only needs to catch +// duplicates and the select-menu-count/mode rules, which validateComponentTree does not +// check on its own (each node is validated independently of its siblings there). +func collectPlacedPanelIds(tree []component.Component) (placed map[int]bool, hasSelect bool, err error) { + placed = make(map[int]bool) + + var walk func(nodes []component.Component) error + walk = func(nodes []component.Component) error { + for _, c := range nodes { + switch c.Type { + case component.ComponentContainer: + container, ok := c.ComponentData.(component.Container) + if !ok { + continue + } + + if err := walk(container.Components); err != nil { + return err + } + case component.ComponentSection: + section, ok := c.ComponentData.(component.Section) + if !ok { + continue + } + + if err := walk(section.Components); err != nil { + return err + } + + if err := walk([]component.Component{section.Accessory}); err != nil { + return err + } + case component.ComponentActionRow: + row, ok := c.ComponentData.(component.ActionRow) + if !ok { + continue + } + + if err := walk(row.Components); err != nil { + return err + } + case component.ComponentButton: + button, ok := c.ComponentData.(component.Button) + if !ok || button.PanelId == nil { + continue + } + + if placed[*button.PanelId] { + return validation.NewInvalidInputErrorf("Panel %d is placed more than once in the message", *button.PanelId) + } + + placed[*button.PanelId] = true + case component.ComponentSelectMenu: + sm, ok := c.ComponentData.(component.SelectMenu) + if !ok || !sm.IsPanelSelect { + continue + } + + if hasSelect { + return validation.NewInvalidInputError("The panel dropdown can only be placed once in the message") + } + + hasSelect = true + } + } + + return nil + } + + if err := walk(tree); err != nil { + return nil, false, err + } + + return placed, hasSelect, nil +} + +// validateComponentTreeBudget enforces Discord's 10-top-level/30-total component limits, +// minus whatever the backend will append on top of the guild's tree (reservedTopLevel/ +// reservedTotal). A pure function so it's directly unit-testable without a gin.Context. +func validateComponentTreeBudget(tree []component.Component, reservedTopLevel, reservedTotal int) error { + total := countComponents(tree) + + if len(tree)+reservedTopLevel > 10 { + return validation.NewInvalidInputErrorf( + "Too many top-level components: %d (maximum %d, %d reserved for system components)", + len(tree), 10-reservedTopLevel, reservedTopLevel, + ) + } + + if total+reservedTotal > 30 { + return validation.NewInvalidInputErrorf( + "Too many components: %d (maximum %d, %d reserved for system components)", + total, 30-reservedTotal, reservedTotal, + ) + } + + return nil +} + +func countComponents(tree []component.Component) int { + count := 0 + + for _, c := range tree { + count++ + + switch c.Type { + case component.ComponentContainer: + if container, ok := c.ComponentData.(component.Container); ok { + count += countComponents(container.Components) + } + case component.ComponentSection: + if section, ok := c.ComponentData.(component.Section); ok { + count += countComponents(section.Components) + if section.Accessory.ComponentData != nil { + count++ + } + } + case component.ComponentActionRow: + // An action row's children (link buttons - see validateActionRow) are leaves, + // so this adds their count directly rather than recursing. + if row, ok := c.ComponentData.(component.ActionRow); ok { + count += len(row.Components) + } + } + } + + return count +} + func validateAutoClose(ctx PanelValidationContext) validation.ValidationFunc { return func() error { ac := ctx.Data.AutoClose diff --git a/backend/app/http/endpoints/api/panel/validation_test.go b/backend/app/http/endpoints/api/panel/validation_test.go new file mode 100644 index 00000000..21b8e2d7 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/validation_test.go @@ -0,0 +1,1451 @@ +package api + +import ( + "fmt" + "testing" + + "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/stretchr/testify/require" +) + +func strPtr(s string) *string { return &s } + +func linkButton(url string) component.Component { + return component.BuildButton(component.Button{ + Label: "Visit", + Style: component.ButtonStyleLink, + Url: strPtr(url), + }) +} + +func linkButtons(n int) []component.Component { + buttons := make([]component.Component, n) + for i := range buttons { + buttons[i] = linkButton(fmt.Sprintf("https://example.com/%d", i)) + } + return buttons +} + +// TestValidateSectionAccessory targets the one deliberate relaxation of the "no +// interactive components anywhere" invariant: a Section's accessory may be a Thumbnail, +// or a link-style Button with no custom_id. Every other shape must still be rejected. +func TestValidateSectionAccessory(t *testing.T) { + tests := []struct { + name string + accessory component.Component + wantErr bool + }{ + { + // Discord's Section object requires an accessory, and gdl's Component.MarshalJSON + // errors out (rather than omitting the key) on a zero-value ComponentData, so a + // missing accessory must be rejected here - otherwise it passes validation but + // then fails to marshal when the message is actually sent. + name: "missing accessory is rejected, not silently accepted", + accessory: component.Component{}, + wantErr: true, + }, + { + name: "valid thumbnail", + accessory: component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "https://example.com/image.png"}}), + wantErr: false, + }, + { + name: "valid link button", + accessory: component.BuildButton(component.Button{ + Label: "Visit", + Style: component.ButtonStyleLink, + Url: strPtr("https://example.com"), + }), + wantErr: false, + }, + { + name: "link style button with a smuggled custom_id", + accessory: component.BuildButton(component.Button{ + Label: "Visit", + Style: component.ButtonStyleLink, + CustomId: "close", // attacker tries to ride a system custom_id through the link exception + Url: strPtr("https://example.com"), + }), + wantErr: true, + }, + { + name: "link style button with any non-system custom_id", + accessory: component.BuildButton(component.Button{ + Label: "Visit", + Style: component.ButtonStyleLink, + CustomId: "attacker_defined_id", + Url: strPtr("https://example.com"), + }), + wantErr: true, + }, + { + name: "primary style button with no custom_id and a url", + accessory: component.BuildButton(component.Button{ + Label: "Click", + Style: component.ButtonStylePrimary, + Url: strPtr("https://example.com"), + }), + wantErr: true, + }, + { + name: "secondary style button with custom_id", + accessory: component.BuildButton(component.Button{ + Label: "Click", + Style: component.ButtonStyleSecondary, + CustomId: "claim", + }), + wantErr: true, + }, + { + name: "success style button with custom_id", + accessory: component.BuildButton(component.Button{ + Label: "Click", + Style: component.ButtonStyleSuccess, + CustomId: "claim", + }), + wantErr: true, + }, + { + name: "danger style button with custom_id", + accessory: component.BuildButton(component.Button{ + Label: "Click", + Style: component.ButtonStyleDanger, + CustomId: "close", + }), + wantErr: true, + }, + { + name: "premium style button (sku, no custom_id, no url)", + accessory: component.BuildButton(component.Button{ + Label: "Buy", + Style: component.ButtonStylePremium, + }), + wantErr: true, + }, + { + name: "link style button with empty url", + accessory: component.BuildButton(component.Button{ + Label: "Visit", + Style: component.ButtonStyleLink, + Url: strPtr(""), + }), + wantErr: true, + }, + { + name: "link style button with nil url", + accessory: component.BuildButton(component.Button{ + Label: "Visit", + Style: component.ButtonStyleLink, + Url: nil, + }), + wantErr: true, + }, + { + name: "string select menu as accessory", + accessory: component.BuildSelectMenu(component.SelectMenu{ + CustomId: "attacker_select", + Options: []component.SelectOption{{Label: "a", Value: "a"}}, + }), + wantErr: true, + }, + { + name: "user select menu as accessory", + accessory: component.BuildUserSelect(component.UserSelect{CustomId: "attacker_select"}), + wantErr: true, + }, + { + name: "role select menu as accessory", + accessory: component.BuildRoleSelect(component.RoleSelect{CustomId: "attacker_select"}), + wantErr: true, + }, + { + name: "mentionable select menu as accessory", + accessory: component.BuildMentionableSelect(component.MentionableSelect{CustomId: "attacker_select"}), + wantErr: true, + }, + { + name: "channel select menu as accessory", + accessory: component.BuildChannelSelect(component.ChannelSelect{CustomId: "attacker_select"}), + wantErr: true, + }, + { + name: "action row as accessory", + accessory: component.BuildActionRow(component.BuildButton(component.Button{ + Label: "x", Style: component.ButtonStylePrimary, CustomId: "close", + })), + wantErr: true, + }, + { + name: "type says button but data is actually a section (type confusion attempt)", + accessory: component.Component{ + Type: component.ComponentButton, + ComponentData: component.Section{}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSectionAccessory(tt.accessory, nil) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestValidateActionRow covers the direct unit boundary cases for a guild-authored +// action row of link buttons: Discord's 5-per-row cap, an empty row, and a row +// containing anything other than a link button. +func TestValidateActionRow(t *testing.T) { + tests := []struct { + name string + row component.ActionRow + wantErr bool + }{ + { + name: "1 link button", + row: component.ActionRow{Components: linkButtons(1)}, + wantErr: false, + }, + { + name: "5 link buttons: at Discord's per-row limit", + row: component.ActionRow{Components: linkButtons(5)}, + wantErr: false, + }, + { + name: "6 link buttons: one over Discord's per-row limit", + row: component.ActionRow{Components: linkButtons(6)}, + wantErr: true, + }, + { + name: "empty row", + row: component.ActionRow{}, + wantErr: true, + }, + { + name: "one non-link-style button among otherwise-valid link buttons", + row: component.ActionRow{ + Components: append(linkButtons(2), component.BuildButton(component.Button{ + Label: "x", Style: component.ButtonStylePrimary, CustomId: "close", + })), + }, + wantErr: true, + }, + { + name: "a select menu inside the row", + row: component.ActionRow{ + Components: []component.Component{ + component.BuildSelectMenu(component.SelectMenu{CustomId: "x", Options: []component.SelectOption{{Label: "a", Value: "a"}}}), + }, + }, + wantErr: true, + }, + { + name: "a link-style button with a smuggled custom_id among the row", + row: component.ActionRow{ + Components: []component.Component{ + component.BuildButton(component.Button{Label: "Visit", Style: component.ButtonStyleLink, CustomId: "close", Url: strPtr("https://example.com")}), + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateActionRow(tt.row, nil, false) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestValidateComponentTree_ActionRowOfLinkButtons confirms the ActionRow-of-link-buttons +// exception is scoped to the top-level/Container-child position only: it must not extend +// to a Section's accessory (still limited to a single thumbnail or link button, never a +// whole row) or to nesting one action row inside another. +func TestValidateComponentTree_ActionRowOfLinkButtons(t *testing.T) { + tests := []struct { + name string + tree []component.Component + wantErr bool + }{ + { + name: "valid: a row of 3 link buttons at top level", + tree: []component.Component{component.BuildActionRow(linkButtons(3)...)}, + wantErr: false, + }, + { + name: "valid: a row of 5 link buttons inside a container", + tree: []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{component.BuildActionRow(linkButtons(5)...)}, + }), + }, + wantErr: false, + }, + { + name: "rejected: a row of 6 link buttons", + tree: []component.Component{component.BuildActionRow(linkButtons(6)...)}, + wantErr: true, + }, + { + name: "rejected: a row mixing a non-link button with link buttons", + tree: []component.Component{ + component.BuildActionRow(append(linkButtons(2), systemCustomIdButton("claim"))...), + }, + wantErr: true, + }, + { + name: "rejected: an action row used as a section's accessory (a row, not a single component)", + tree: []component.Component{ + component.BuildSection(component.Section{ + Components: []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "hi"})}, + Accessory: component.BuildActionRow(linkButtons(1)...), + }), + }, + wantErr: true, + }, + { + name: "rejected: an action row nested inside another action row", + tree: []component.Component{ + component.BuildActionRow(component.BuildActionRow(linkButtons(1)...)), + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateComponentTree(tt.tree, nil, false) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func systemCustomIdButton(customId string) component.Component { + return component.BuildButton(component.Button{ + Label: "x", + Style: component.ButtonStylePrimary, + CustomId: customId, + }) +} + +// TestValidateComponentTree_RejectsInteractiveComponentsEverywhere confirms that the +// link-button relaxation is scoped to exactly the Section.Accessory position, and that +// every interactive component type is rejected at every other position/depth. +func TestValidateComponentTree_RejectsInteractiveComponentsEverywhere(t *testing.T) { + tests := []struct { + name string + tree []component.Component + wantErr bool + }{ + { + name: "top level action row with a button", + tree: []component.Component{component.BuildActionRow(systemCustomIdButton("close"))}, + wantErr: true, + }, + { + name: "top level button (not inside an action row)", + tree: []component.Component{systemCustomIdButton("claim")}, + wantErr: true, + }, + { + name: "button directly inside a container's components (not a section accessory)", + tree: []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{systemCustomIdButton("close")}, + }), + }, + wantErr: true, + }, + { + name: "a link button placed directly in a container's components, not as a section accessory", + tree: []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildButton(component.Button{Label: "x", Style: component.ButtonStyleLink, Url: strPtr("https://example.com")}), + }, + }), + }, + wantErr: true, + }, + { + name: "action row nested inside a container", + tree: []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildActionRow(systemCustomIdButton("close")), + }, + }), + }, + wantErr: true, + }, + { + name: "action row nested inside a section's components", + tree: []component.Component{ + component.BuildSection(component.Section{ + Components: []component.Component{ + component.BuildActionRow(systemCustomIdButton("close")), + }, + Accessory: component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "https://example.com/i.png"}}), + }), + }, + wantErr: true, + }, + { + name: "select menu nested inside a section's components", + tree: []component.Component{ + component.BuildSection(component.Section{ + Components: []component.Component{ + component.BuildSelectMenu(component.SelectMenu{CustomId: "x", Options: []component.SelectOption{{Label: "a", Value: "a"}}}), + }, + }), + }, + wantErr: true, + }, + { + name: "action row nested two levels deep (container > container > action row)", + tree: []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildActionRow(systemCustomIdButton("close")), + }, + }), + }, + }), + }, + wantErr: true, + }, + { + name: "thumbnail as a top-level entry (not a section accessory)", + tree: []component.Component{ + component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "https://example.com/i.png"}}), + }, + wantErr: true, + }, + { + name: "thumbnail nested inside a container's components (not a section accessory)", + tree: []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "https://example.com/i.png"}}), + }, + }), + }, + wantErr: true, + }, + { + name: "input text nested inside a container", + tree: []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildInputText(component.InputText{CustomId: "x"}), + }, + }), + }, + wantErr: true, + }, + { + name: "unsupported/unknown component type", + tree: []component.Component{{Type: component.ComponentType(255)}}, + wantErr: true, + }, + { + name: "valid tree: container containing a section with a thumbnail accessory", + tree: []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildSection(component.Section{ + Components: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "hello"}), + }, + Accessory: component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "https://example.com/i.png"}}), + }), + }, + }), + }, + wantErr: false, + }, + { + name: "valid tree: section with a link-button accessory", + tree: []component.Component{ + component.BuildSection(component.Section{ + Components: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "hello"}), + }, + Accessory: component.BuildButton(component.Button{Label: "Visit", Style: component.ButtonStyleLink, Url: strPtr("https://example.com")}), + }), + }, + wantErr: false, + }, + { + name: "valid tree: bare leaf types at top level", + tree: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "hi"}), + component.BuildSeparator(component.Separator{}), + component.BuildMediaGallery(component.MediaGallery{}), + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateComponentTree(tt.tree, nil, false) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestValidateComponentTree_ValidTreesAreMarshalable is a regression guard for the +// invariant that anything validateComponentTree accepts must actually be serialisable - +// gdl's Component.MarshalJSON errors (rather than omitting the key) on a Section with a +// zero-value accessory, so a validator that is more lenient than the marshaller produces +// a tree that passes validation but then 500s when the message is actually sent. +func TestValidateComponentTree_ValidTreesAreMarshalable(t *testing.T) { + tests := []struct { + name string + tree []component.Component + }{ + { + name: "section with thumbnail accessory", + tree: []component.Component{ + component.BuildSection(component.Section{ + Components: []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "hi"})}, + Accessory: component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "https://example.com/i.png"}}), + }), + }, + }, + { + name: "section with link button accessory", + tree: []component.Component{ + component.BuildSection(component.Section{ + Components: []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "hi"})}, + Accessory: component.BuildButton(component.Button{Label: "Visit", Style: component.ButtonStyleLink, Url: strPtr("https://example.com")}), + }), + }, + }, + { + name: "container wrapping a section with a thumbnail accessory", + tree: []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildSection(component.Section{ + Components: []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "hi"})}, + Accessory: component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "https://example.com/i.png"}}), + }), + }, + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.NoError(t, validateComponentTree(tt.tree, nil, false), "tree should pass validation") + + _, err := marshalComponents(tt.tree) + require.NoError(t, err, "a tree that passes validation must also be marshalable for storage/sending") + }) + } +} + +// TestValidateComponentTree_DepthLimit ensures a pathologically deep (but otherwise cheap) +// attacker-supplied tree is rejected quickly rather than triggering unbounded recursion. +func TestValidateComponentTree_DepthLimit(t *testing.T) { + // Build a long chain of nested containers, each with exactly one child. + const depth = 100 + + var leaf component.Component = component.BuildTextDisplay(component.TextDisplay{Content: "leaf"}) + tree := []component.Component{leaf} + for i := 0; i < depth; i++ { + tree = []component.Component{component.BuildContainer(component.Container{Components: tree})} + } + + err := validateComponentTree(tree, nil, false) + require.Error(t, err, "a tree nested %d levels deep should be rejected rather than fully walked", depth) +} + +// TestValidateComponentTreeBudget covers the exact boundary of Discord's 10-top-level / +// 30-total component limits once the reserved system-component budget is subtracted, for +// each surface's reservation formula. +func TestValidateComponentTreeBudget(t *testing.T) { + textDisplay := func() component.Component { + return component.BuildTextDisplay(component.TextDisplay{Content: "x"}) + } + + makeTree := func(n int) []component.Component { + tree := make([]component.Component, n) + for i := range tree { + tree[i] = textDisplay() + } + return tree + } + + tests := []struct { + name string + tree []component.Component + reservedTopLevel int + reservedTotal int + wantErr bool + }{ + { + name: "panel button-message: exactly at budget (9 top-level, reserve 1/2)", + tree: makeTree(9), + reservedTopLevel: 1, + reservedTotal: 2, + wantErr: false, // 9+1=10 top level, 9+2=11 total, both within limits + }, + { + name: "panel button-message: one over top-level budget", + tree: makeTree(10), + reservedTopLevel: 1, + reservedTotal: 2, + wantErr: true, // 10+1=11 > 10 + }, + { + name: "panel button-message: within top-level but over total budget via nesting", + // 1 top-level container containing 28 text displays: 1 top-level (ok, 1+1=2<=10), + // but total = 1 (container) + 28 (children) = 29; 29+2=31 > 30. + tree: []component.Component{ + component.BuildContainer(component.Container{Components: makeTree(28)}), + }, + reservedTopLevel: 1, + reservedTotal: 2, + wantErr: true, + }, + { + name: "panel button-message: exactly at total budget via nesting", + // 1 top-level container containing 27 text displays: total = 1+27=28; 28+2=30<=30. + tree: []component.Component{ + component.BuildContainer(component.Container{Components: makeTree(27)}), + }, + reservedTopLevel: 1, + reservedTotal: 2, + wantErr: false, + }, + { + name: "multi-panel button mode at N=15 targets: tightest reservation (reserve 3/18)", + tree: makeTree(7), + reservedTopLevel: 3, + reservedTotal: 18, + wantErr: false, // 7+3=10, 7+18=25 + }, + { + name: "multi-panel button mode at N=15 targets: one top-level component too many", + tree: makeTree(8), + reservedTopLevel: 3, + reservedTotal: 18, + wantErr: true, // 8+3=11 > 10 + }, + { + name: "multi-panel select-menu mode: reserve 1/2", + tree: makeTree(9), + reservedTopLevel: 1, + reservedTotal: 2, + wantErr: false, + }, + { + name: "welcome message with all 3 buttons visible: reserve 2/7", + tree: makeTree(8), + reservedTopLevel: 2, + reservedTotal: 7, + wantErr: false, // 8+2=10, 8+7=15 + }, + { + name: "welcome message with all 3 buttons visible: one over", + tree: makeTree(9), + reservedTopLevel: 2, + reservedTotal: 7, + wantErr: true, // 9+2=11 > 10 + }, + { + name: "welcome message with no buttons visible: reserve 1/3", + tree: makeTree(9), + reservedTopLevel: 1, + reservedTotal: 3, + wantErr: false, + }, + { + name: "empty tree always within budget", + tree: nil, + reservedTopLevel: 3, + reservedTotal: 18, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateComponentTreeBudget(tt.tree, tt.reservedTopLevel, tt.reservedTotal) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestCountComponents verifies the component counter actually recurses into every place +// a component array can appear, so it can't be undercounted via nesting. +func TestCountComponents(t *testing.T) { + tests := []struct { + name string + tree []component.Component + want int + }{ + { + name: "flat list", + tree: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "a"}), + component.BuildTextDisplay(component.TextDisplay{Content: "b"}), + }, + want: 2, + }, + { + name: "container with children counts container + each child", + tree: []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "a"}), + component.BuildTextDisplay(component.TextDisplay{Content: "b"}), + }, + }), + }, + want: 3, // container + 2 children + }, + { + name: "section with children and accessory counts section + children + accessory", + tree: []component.Component{ + component.BuildSection(component.Section{ + Components: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "a"}), + }, + Accessory: component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "https://example.com/i.png"}}), + }), + }, + want: 3, // section + 1 child + 1 accessory + }, + { + name: "section with no accessory does not overcount", + tree: []component.Component{ + component.BuildSection(component.Section{ + Components: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "a"}), + }, + }), + }, + want: 2, // section + 1 child, no accessory + }, + { + name: "nested container inside container counts every level", + tree: []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "a"}), + }, + }), + }, + }), + }, + want: 3, // outer container + inner container + text display + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := countComponents(tt.tree) + require.Equal(t, tt.want, got) + }) + } +} + +// TestValidateMessageComponents_PremiumGate confirms a Free-tier guild cannot enable the +// panel button-message Components V2 tree, and that a Premium guild with an invalid tree +// still gets rejected on content, not waved through once premium is confirmed. +func TestValidateMessageComponents_PremiumGate(t *testing.T) { + validTree := []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "hi"})} + maliciousTree := []component.Component{component.BuildActionRow(systemCustomIdButton("close"))} + + tests := []struct { + name string + isPremium bool + enabled bool + tree []component.Component + wantErr bool + }{ + { + name: "flag off on free tier: tree ignored, always valid", + isPremium: false, + enabled: false, + tree: maliciousTree, + wantErr: false, + }, + { + name: "flag on but not premium: rejected regardless of tree content", + isPremium: false, + enabled: true, + tree: validTree, + wantErr: true, + }, + { + name: "flag on and premium, valid tree: accepted", + isPremium: true, + enabled: true, + tree: validTree, + wantErr: false, + }, + { + name: "flag on and premium, malicious tree: still rejected", + isPremium: true, + enabled: true, + tree: maliciousTree, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := PanelValidationContext{ + Data: panelBody{ + MessageUsesComponentsV2: tt.enabled, + MessageComponents: tt.tree, + }, + IsPremium: tt.isPremium, + } + + err := validateMessageComponents(ctx)() + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestValidateWelcomeMessageComponents_PremiumGate mirrors the button-message premium +// gate test for the welcome-message surface, and exercises the visible-button-count +// dependent reservation. +func TestValidateWelcomeMessageComponents_PremiumGate(t *testing.T) { + t.Run("free tier rejected", func(t *testing.T) { + ctx := PanelValidationContext{ + Data: panelBody{ + WelcomeMessageUsesComponentsV2: true, + WelcomeMessageComponents: []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "hi"})}, + }, + IsPremium: false, + } + + err := validateWelcomeMessageComponents(ctx)() + require.Error(t, err) + }) + + t.Run("malicious tree rejected even when premium", func(t *testing.T) { + ctx := PanelValidationContext{ + Data: panelBody{ + WelcomeMessageUsesComponentsV2: true, + WelcomeMessageComponents: []component.Component{ + component.BuildSection(component.Section{ + Accessory: component.BuildSelectMenu(component.SelectMenu{CustomId: "x", Options: []component.SelectOption{{Label: "a", Value: "a"}}}), + }), + }, + }, + IsPremium: true, + } + + err := validateWelcomeMessageComponents(ctx)() + require.Error(t, err) + }) + + t.Run("premium, valid tree, budget accounts for all three visible buttons", func(t *testing.T) { + tree := make([]component.Component, 8) + for i := range tree { + tree[i] = component.BuildTextDisplay(component.TextDisplay{Content: fmt.Sprintf("field %d", i)}) + } + + ctx := PanelValidationContext{ + Data: panelBody{ + WelcomeMessageUsesComponentsV2: true, + WelcomeMessageComponents: tree, + HideCloseButton: false, + HideCloseWithReasonButton: false, + HideClaimButton: false, + }, + IsPremium: true, + } + + // reservedTopLevel = 1 (form placeholder) + 1 (button row) = 2 + // reservedTotal = 3 (form placeholder) + 3 buttons + 1 row = 7 + // 8 top-level components: 8+2=10 (at budget); total 8+7=15 (within budget) + err := validateWelcomeMessageComponents(ctx)() + require.NoError(t, err) + + // One more top-level component should now exceed the top-level budget. + ctxOverBudget := ctx + ctxOverBudget.Data.WelcomeMessageComponents = append(tree, component.BuildTextDisplay(component.TextDisplay{Content: "one too many"})) + err = validateWelcomeMessageComponents(ctxOverBudget)() + require.Error(t, err) + }) + + t.Run("premium, all buttons hidden, budget only reserves the form placeholder", func(t *testing.T) { + tree := make([]component.Component, 9) + for i := range tree { + tree[i] = component.BuildTextDisplay(component.TextDisplay{Content: fmt.Sprintf("field %d", i)}) + } + + ctx := PanelValidationContext{ + Data: panelBody{ + WelcomeMessageUsesComponentsV2: true, + WelcomeMessageComponents: tree, + HideCloseButton: true, + HideCloseWithReasonButton: true, + HideClaimButton: true, + }, + IsPremium: true, + } + + // reservedTopLevel = 1, reservedTotal = 3; 9+1=10 top level (at budget) + err := validateWelcomeMessageComponents(ctx)() + require.NoError(t, err) + }) +} + +// TestValidateMultiPanelComponents exercises the two reservation formulas (select-menu +// mode vs button mode) including the tightest documented boundary case: N=15 targets in +// button mode reserves 3 rows and 18 total slots. +func TestValidateMultiPanelComponents(t *testing.T) { + makeTree := func(n int) []component.Component { + tree := make([]component.Component, n) + for i := range tree { + tree[i] = component.BuildTextDisplay(component.TextDisplay{Content: fmt.Sprintf("x%d", i)}) + } + return tree + } + + panelsOfLen := func(n int) []panelConfiguration { + panels := make([]panelConfiguration, n) + for i := range panels { + panels[i] = panelConfiguration{PanelId: i} + } + return panels + } + + tests := []struct { + name string + data multiPanelCreateData + wantErr bool + }{ + { + name: "flag off: malicious tree ignored", + data: multiPanelCreateData{ + UsesComponentsV2: false, + Components: []component.Component{component.BuildActionRow(systemCustomIdButton("multipanel"))}, + }, + wantErr: false, + }, + { + name: "select menu mode: at budget (9 top level, reserve 1/2)", + data: multiPanelCreateData{ + UsesComponentsV2: true, + SelectMenu: true, + Components: makeTree(9), + }, + wantErr: false, + }, + { + name: "select menu mode: over budget", + data: multiPanelCreateData{ + UsesComponentsV2: true, + SelectMenu: true, + Components: makeTree(10), + }, + wantErr: true, + }, + { + name: "button mode, N=15 targets: tightest case, at budget (7 top level)", + data: multiPanelCreateData{ + UsesComponentsV2: true, + SelectMenu: false, + Panels: panelsOfLen(15), + Components: makeTree(7), + }, + wantErr: false, // reserves rows=3, total=18; 7+3=10, 7+18=25 + }, + { + name: "button mode, N=15 targets: one top-level component too many", + data: multiPanelCreateData{ + UsesComponentsV2: true, + SelectMenu: false, + Panels: panelsOfLen(15), + Components: makeTree(8), + }, + wantErr: true, // 8+3=11 > 10 + }, + { + name: "button mode, malicious tree rejected outright regardless of budget", + data: multiPanelCreateData{ + UsesComponentsV2: true, + SelectMenu: false, + Panels: panelsOfLen(2), + Components: []component.Component{systemCustomIdButton("attacker")}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateMultiPanelComponents(tt.data) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func panelButton(panelId *int, customId string, url *string) component.Component { + return component.BuildButton(component.Button{ + Label: "x", + Style: component.ButtonStylePrimary, + CustomId: customId, + Url: url, + PanelId: panelId, + }) +} + +func intPtr(n int) *int { return &n } + +// TestIsPanelButton covers the load-bearing security rule for panel placement: a button +// is only ever treated as a panel placeholder when it carries no client-supplied +// custom_id/url and its PanelId is a member of validPanelIds. A component that smuggles +// both a custom_id and a panel_id must never be accepted. +func TestIsPanelButton(t *testing.T) { + validIds := map[int]bool{1: true, 2: true} + + tests := []struct { + name string + c component.Component + want bool + }{ + { + name: "valid panel-id-only button", + c: panelButton(intPtr(1), "", nil), + want: true, + }, + { + name: "panel id not in the valid set", + c: panelButton(intPtr(99), "", nil), + want: false, + }, + { + name: "smuggled custom_id alongside a valid panel_id is rejected outright", + c: panelButton(intPtr(1), "attacker_defined_id", nil), + want: false, + }, + { + name: "smuggled url alongside a valid panel_id is rejected outright", + c: panelButton(intPtr(1), "", strPtr("https://example.com")), + want: false, + }, + { + name: "no panel_id at all is not a panel button", + c: panelButton(nil, "", nil), + want: false, + }, + { + name: "not a button at all", + c: component.BuildTextDisplay(component.TextDisplay{Content: "hi"}), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, isPanelButton(tt.c, validIds)) + }) + } +} + +// TestIsPanelSelect confirms the panel-select placeholder is only recognised when the +// multi-panel is actually in dropdown mode, and only for a clean placeholder node (no +// custom_id, no options already attached). +func TestIsPanelSelect(t *testing.T) { + placeholder := component.BuildSelectMenu(component.SelectMenu{IsPanelSelect: true}) + + tests := []struct { + name string + c component.Component + selectMenuModeOn bool + want bool + }{ + { + name: "valid placeholder in dropdown mode", + c: placeholder, + selectMenuModeOn: true, + want: true, + }, + { + name: "same placeholder rejected outright when not in dropdown mode", + c: placeholder, + selectMenuModeOn: false, + want: false, + }, + { + name: "custom_id already set is not a clean placeholder", + c: component.BuildSelectMenu(component.SelectMenu{ + IsPanelSelect: true, + CustomId: "attacker_select", + }), + selectMenuModeOn: true, + want: false, + }, + { + name: "options already populated is not a clean placeholder", + c: component.BuildSelectMenu(component.SelectMenu{ + IsPanelSelect: true, + Options: []component.SelectOption{{Label: "a", Value: "a"}}, + }), + selectMenuModeOn: true, + want: false, + }, + { + name: "IsPanelSelect not set at all", + c: component.BuildSelectMenu(component.SelectMenu{CustomId: "x"}), + selectMenuModeOn: true, + want: false, + }, + { + name: "not a select menu at all", + c: component.BuildTextDisplay(component.TextDisplay{Content: "hi"}), + selectMenuModeOn: true, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, isPanelSelect(tt.c, tt.selectMenuModeOn)) + }) + } +} + +// TestCollectPlacedPanelIds covers duplicate-placement detection, the panel-select +// one-only rule, and the happy path across a nested tree. +func TestCollectPlacedPanelIds(t *testing.T) { + t.Run("clean tree with buttons in an action row and one nested in a section", func(t *testing.T) { + tree := []component.Component{ + component.BuildActionRow(panelButton(intPtr(1), "", nil), panelButton(intPtr(2), "", nil)), + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildSection(component.Section{ + Components: []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "hi"})}, + Accessory: panelButton(intPtr(3), "", nil), + }), + }, + }), + } + + placed, hasSelect, err := collectPlacedPanelIds(tree) + require.NoError(t, err) + require.False(t, hasSelect) + require.Equal(t, map[int]bool{1: true, 2: true, 3: true}, placed) + }) + + t.Run("clean tree with exactly one panel-select placeholder", func(t *testing.T) { + tree := []component.Component{ + component.BuildActionRow(panelButton(intPtr(1), "", nil)), + component.BuildSelectMenu(component.SelectMenu{IsPanelSelect: true}), + } + + placed, hasSelect, err := collectPlacedPanelIds(tree) + require.NoError(t, err) + require.True(t, hasSelect) + require.Equal(t, map[int]bool{1: true}, placed) + }) + + t.Run("duplicate panel_id across two placements is rejected", func(t *testing.T) { + tree := []component.Component{ + component.BuildActionRow(panelButton(intPtr(1), "", nil)), + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildActionRow(panelButton(intPtr(1), "", nil)), + }, + }), + } + + _, _, err := collectPlacedPanelIds(tree) + require.Error(t, err) + }) + + t.Run("two panel-select placeholders is rejected", func(t *testing.T) { + tree := []component.Component{ + component.BuildSelectMenu(component.SelectMenu{IsPanelSelect: true}), + component.BuildSelectMenu(component.SelectMenu{IsPanelSelect: true}), + } + + _, _, err := collectPlacedPanelIds(tree) + require.Error(t, err) + }) + + t.Run("no placements at all", func(t *testing.T) { + tree := []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "hi"})} + + placed, hasSelect, err := collectPlacedPanelIds(tree) + require.NoError(t, err) + require.False(t, hasSelect) + require.Empty(t, placed) + }) +} + +func testPanelWithCustomization(panelId int, buttonLabel, customId string) database.PanelWithCustomization { + return database.PanelWithCustomization{ + Panel: database.Panel{ + PanelId: panelId, + ButtonLabel: buttonLabel, + CustomId: customId, + ButtonStyle: int(component.ButtonStylePrimary), + }, + } +} + +// TestBuildSystemComponents_PlacedPanelsExcluded confirms buildSystemComponents never +// duplicates a panel that has already been placed inside the guild-authored tree, in +// both button mode and dropdown mode, and returns nil rather than an empty slice once +// every panel has been placed. +func TestBuildSystemComponents_PlacedPanelsExcluded(t *testing.T) { + panels := []database.PanelWithCustomization{ + testPanelWithCustomization(1, "One", "custom-1"), + testPanelWithCustomization(2, "Two", "custom-2"), + } + + t.Run("button mode: all panels placed returns nil, not an empty slice", func(t *testing.T) { + d := multiPanelMessageData{ + UsesComponentsV2: true, + Components: []component.Component{ + component.BuildActionRow(panelButton(intPtr(1), "", nil), panelButton(intPtr(2), "", nil)), + }, + } + + got := d.buildSystemComponents(panels) + require.Nil(t, got) + }) + + t.Run("button mode: one panel placed leaves the other in the default row", func(t *testing.T) { + d := multiPanelMessageData{ + UsesComponentsV2: true, + Components: []component.Component{ + component.BuildActionRow(panelButton(intPtr(1), "", nil)), + }, + } + + got := d.buildSystemComponents(panels) + require.Len(t, got, 1) + + row, ok := got[0].ComponentData.(component.ActionRow) + require.True(t, ok) + require.Len(t, row.Components, 1) + + button, ok := row.Components[0].ComponentData.(component.Button) + require.True(t, ok) + require.Equal(t, "custom-2", button.CustomId) + }) + + t.Run("dropdown mode: the placed panel-select consumes both panels, default row is nil", func(t *testing.T) { + d := multiPanelMessageData{ + UsesComponentsV2: true, + SelectMenu: true, + Components: []component.Component{ + component.BuildSelectMenu(component.SelectMenu{IsPanelSelect: true}), + }, + } + + got := d.buildSystemComponents(panels) + require.Nil(t, got) + }) + + t.Run("classic mode ignores Components entirely: nothing is treated as placed", func(t *testing.T) { + d := multiPanelMessageData{ + UsesComponentsV2: false, + Components: []component.Component{ + component.BuildActionRow(panelButton(intPtr(1), "", nil)), + }, + } + + got := d.buildSystemComponents(panels) + require.Len(t, got, 1) + + row, ok := got[0].ComponentData.(component.ActionRow) + require.True(t, ok) + require.Len(t, row.Components, 2) + }) + + t.Run("no placement at all: both panels appear in the default row", func(t *testing.T) { + d := multiPanelMessageData{UsesComponentsV2: true} + + got := d.buildSystemComponents(panels) + require.Len(t, got, 1) + + row, ok := got[0].ComponentData.(component.ActionRow) + require.True(t, ok) + require.Len(t, row.Components, 2) + }) +} + +// TestResolveComponentTreePanelRefs_ClearsPlaceholderMarkers confirms a resolved button +// carries a real custom_id and no PanelId, and a resolved select carries the system +// custom_id, the unplaced panels as options, and IsPanelSelect cleared. +func TestResolveComponentTreePanelRefs_ClearsPlaceholderMarkers(t *testing.T) { + panels := []database.PanelWithCustomization{ + testPanelWithCustomization(1, "One", "custom-1"), + testPanelWithCustomization(2, "Two", "custom-2"), + } + + tree := []component.Component{ + component.BuildActionRow(panelButton(intPtr(1), "", nil)), + component.BuildSelectMenu(component.SelectMenu{IsPanelSelect: true}), + } + + resolved := resolveComponentTreePanelRefs(tree, panels) + require.Len(t, resolved, 2) + + row, ok := resolved[0].ComponentData.(component.ActionRow) + require.True(t, ok) + require.Len(t, row.Components, 1) + + button, ok := row.Components[0].ComponentData.(component.Button) + require.True(t, ok) + require.Equal(t, "custom-1", button.CustomId) + require.Nil(t, button.PanelId) + + sm, ok := resolved[1].ComponentData.(component.SelectMenu) + require.True(t, ok) + require.Equal(t, "multipanel", sm.CustomId) + require.False(t, sm.IsPanelSelect) + // Panel 1 was placed as a button, so only panel 2 remains for the dropdown. + require.Len(t, sm.Options, 1) + require.Equal(t, "custom-2", sm.Options[0].Value) +} + +// TestResolveComponentTreePanelRefs_DropsSelectWithNoPanelsLeft covers a placed +// panel-select whose remaining panels have all disappeared since the tree was saved (for +// example the last unplaced panel was deleted from the guild via paneldelete.go). Discord +// rejects a select menu with zero options, so the resolved output must drop the node +// rather than emit one, and the parent action row must drop with it. +func TestResolveComponentTreePanelRefs_DropsSelectWithNoPanelsLeft(t *testing.T) { + panels := []database.PanelWithCustomization{ + testPanelWithCustomization(1, "One", "custom-1"), + } + + tree := []component.Component{ + component.BuildActionRow(panelButton(intPtr(1), "", nil)), + component.BuildActionRow(component.BuildSelectMenu(component.SelectMenu{IsPanelSelect: true})), + } + + resolved := resolveComponentTreePanelRefs(tree, panels) + require.Len(t, resolved, 1, "the select's action row must be dropped, leaving only the button's row") + + row, ok := resolved[0].ComponentData.(component.ActionRow) + require.True(t, ok) + require.Len(t, row.Components, 1) + + button, ok := row.Components[0].ComponentData.(component.Button) + require.True(t, ok) + require.Equal(t, "custom-1", button.CustomId) +} + +// TestValidateMultiPanelComponents_PlacedPanelReservation covers the reservation formula +// once panels are placed inside the tree: a placed panel-select reserves zero extra +// budget (it's already counted as part of the authored tree), and placed buttons shrink +// the button-mode row reservation to just the panels left unplaced. +func TestValidateMultiPanelComponents_PlacedPanelReservation(t *testing.T) { + panelsOfLen := func(n int) []panelConfiguration { + panels := make([]panelConfiguration, n) + for i := range panels { + panels[i] = panelConfiguration{PanelId: i} + } + return panels + } + + t.Run("dropdown mode, panel-select placed in the tree: reserves nothing extra", func(t *testing.T) { + // 9 top-level text displays + 1 row containing the placed select = 10 top level, + // at budget with zero reservation; without the fix this would reserve an extra + // 1/2 and fail. + tree := make([]component.Component, 9) + for i := range tree { + tree[i] = component.BuildTextDisplay(component.TextDisplay{Content: fmt.Sprintf("x%d", i)}) + } + tree = append(tree, component.BuildActionRow(component.BuildSelectMenu(component.SelectMenu{IsPanelSelect: true}))) + + data := multiPanelCreateData{ + UsesComponentsV2: true, + SelectMenu: true, + Panels: panelsOfLen(3), + Components: tree, + } + + require.NoError(t, validateMultiPanelComponents(data)) + }) + + t.Run("dropdown mode: placing the select with zero panels left unplaced is rejected", func(t *testing.T) { + data := multiPanelCreateData{ + UsesComponentsV2: true, + SelectMenu: true, + Panels: panelsOfLen(1), + Components: []component.Component{ + component.BuildActionRow(panelButton(intPtr(0), "", nil)), + component.BuildActionRow(component.BuildSelectMenu(component.SelectMenu{IsPanelSelect: true})), + }, + } + + require.Error(t, validateMultiPanelComponents(data)) + }) + + t.Run("button mode: placed panels shrink the row reservation to the unplaced remainder", func(t *testing.T) { + // 15 targets, 1 placed directly as a button in the tree: 14 remain for the + // default row, needing 3 rows (ceil(14/5)) instead of the full 3 rows for 15 - + // exercised here at the boundary where 8 top-level components would have failed + // against the un-adjusted (3, 18) reservation for 15 panels, but now the eighth + // slot is the placed button itself, leaving only 7 "real" tree entries plus the + // placed button = 8, reserving rows=ceil(14/5)=3, so 8+3=11 fails; drop to 7 tree + // entries (6 filler + 1 placed button) so 7+3=10 passes. + tree := make([]component.Component, 6) + for i := range tree { + tree[i] = component.BuildTextDisplay(component.TextDisplay{Content: fmt.Sprintf("x%d", i)}) + } + tree = append(tree, component.BuildActionRow(panelButton(intPtr(0), "", nil))) + + data := multiPanelCreateData{ + UsesComponentsV2: true, + SelectMenu: false, + Panels: panelsOfLen(15), + Components: tree, + } + + require.NoError(t, validateMultiPanelComponents(data)) + }) +} diff --git a/backend/app/http/endpoints/api/user/featureflags.go b/backend/app/http/endpoints/api/user/featureflags.go index c8e4f438..a23dec25 100644 --- a/backend/app/http/endpoints/api/user/featureflags.go +++ b/backend/app/http/endpoints/api/user/featureflags.go @@ -32,6 +32,7 @@ var browserFlags = []string{ "202608_FEATURE_WHITELABEL", "202608_FEATURE_INTEGRATIONS", "202608_FEATURE_AUTOMATIONS", + "202608_COMPONENTS_V2_BUILDER", } // evalBrowserFlags evaluates the allowlisted flags against the given attributes. diff --git a/backend/go.mod b/backend/go.mod index f95e4759..e0ea0072 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,7 +2,9 @@ module github.com/ticketsbot-cloud/dashboard/backend go 1.26.0 -// replace github.com/TicketsBot-cloud/database => ../../database +replace github.com/TicketsBot-cloud/database => ../../database + +replace github.com/TicketsBot-cloud/gdl => ../../gdl // replace github.com/TicketsBot-cloud/common => ../../common diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 130f42b1..eeb04f7d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,10 @@ "name": "tickets-dashboard", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^3.2.0", @@ -338,6 +342,73 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index ae10c02b..889b4086 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,10 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^3.2.0", diff --git a/frontend/src/components/PanelPreview.tsx b/frontend/src/components/PanelPreview.tsx index b78ab293..e5c2e5f9 100644 --- a/frontend/src/components/PanelPreview.tsx +++ b/frontend/src/components/PanelPreview.tsx @@ -1,12 +1,17 @@ -import type { FC } from "react"; +import type { FC, ReactNode } from "react"; import Button from "@/components/discord/container/Button"; import SelectMenu from "@/components/discord/container/SelectMenu"; import DiscordContent from "@/components/discord/DiscordContent"; +import DiscordComponents from "@/components/discord/DiscordComponents"; +import AutoAppendedTag from "@/components/component-builder/AutoAppendedTag"; import type { Panel, MultiPanel, MultiPanelRequest } from "@/types"; import { BRANDING_FOOTER_ICON, BRANDING_FOOTER_TEXT } from "@/lib/constants"; import { formatEmbedTimestampForDisplay } from "@/lib/embed-timestamp"; import { isSafeUrl } from "@/lib/url"; import { previewAvatarUrl } from "@/lib/embed-avatar"; +import { toPreviewComponents, type V2Component } from "@/lib/component-tree"; + +const EMPTY_ENTITIES = { users: {}, channels: {}, roles: {} }; function resolveEmoteName(emote: Panel["emote"] | undefined): string { if (typeof emote === "string") return emote; @@ -24,13 +29,66 @@ interface PanelPreviewProps { buttons?: Panel[]; }; brandingFooter?: boolean; + messageMode?: "classic" | "components_v2"; + componentTree?: V2Component[]; + /** + * Every panel in the multipanel (or single panel builder's containing panel, where the + * concept never applies), passed through to `toPreviewComponents` so a placed panel node + * anywhere in `componentTree` can be resolved to what it will actually render as. Omitted + * entirely for the single-panel and welcome-message builders, which have no "panels" concept - + * `toPreviewComponents` degrades to skipping any panel-kind node in that case, which never + * exist in those trees anyway. + */ + panelsForPreview?: Panel[]; } -const PanelPreview: FC = ({ type, data, brandingFooter }) => { +const PanelPreview: FC = ({ + type, + data, + brandingFooter, + messageMode = "classic", + componentTree, + panelsForPreview, +}) => { const { panel, buttons } = data; + const isV2 = messageMode === "components_v2"; + + const buttonsRow = (content: ReactNode) => + !content ? null : isV2 ? ( + {content} + ) : ( + content + ); if (type === "panel") { const p = panel as Panel; + const buttonsContent = + buttons && buttons.length > 0 ? ( +
+ {buttons.map((bp, index) => ( +
+ ) : null; + return ( <>
= ({ type, data, brandingFooter }) => borderLeftColor: `#${(p.colour || 0x5865f2).toString(16).padStart(6, "0")}`, }} > -
-
- - -
- {p.thumbnail_url && ( -
- Thumbnail + {isV2 ? ( + + ) : ( + <> +
+
+ + +
+ {p.thumbnail_url && ( +
+ Thumbnail +
+ )}
- )} -
- {p.image_url && ( -
- Embedded -
+ {p.image_url && ( +
+ Embedded +
+ )} + )}
- {buttons && buttons.length > 0 && ( -
- {buttons.map((bp, index) => ( -
- )} + {buttonsRow(buttonsContent)} ); } @@ -134,141 +180,156 @@ const PanelPreview: FC = ({ type, data, brandingFooter }) => className="bg-[#2b2d31] rounded border-l-4 p-3 w-full sm:max-w-130 mt-4" style={{ borderLeftColor: borderColor }} > -
-
- {authorUrl && isSafeUrl(authorUrl) ? ( - - {authorIconUrl && ( - Author Icon - )} - - {embedData?.author?.name} - - - ) : embedData?.author?.name ? ( -
- {authorIconUrl && ( - Author Icon + {isV2 ? ( + + ) : ( + <> +
+
+ {authorUrl && isSafeUrl(authorUrl) ? ( + + {authorIconUrl && ( + Author Icon + )} + + {embedData?.author?.name} + + + ) : embedData?.author?.name ? ( +
+ {authorIconUrl && ( + Author Icon + )} + + {embedData.author.name} + +
+ ) : null} + {embedData?.title && + (titleUrl && isSafeUrl(titleUrl) ? ( + + + + ) : ( + + ))} + {embedData?.description && ( + )} - - {embedData.author.name} -
- ) : null} - {embedData?.title && - (titleUrl && isSafeUrl(titleUrl) ? ( - - + Thumbnail - - ) : ( - - ))} - {embedData?.description && ( - - )} -
- {thumbnailUrl && ( -
- Thumbnail +
+ )}
- )} -
- {imageUrl && ( -
- Embedded -
- )} - {(displayFooterText || footerTimestamp) && ( -
- {displayFooterIconUrl && ( - Footer Icon + {imageUrl && ( +
+ Embedded +
+ )} + {(displayFooterText || footerTimestamp) && ( +
+ {displayFooterIconUrl && ( + Footer Icon + )} +

+ {displayFooterText} + {displayFooterText && footerTimestamp ? " • " : ""} + {footerTimestamp} +

+
)} -

- {displayFooterText} - {displayFooterText && footerTimestamp ? " • " : ""} - {footerTimestamp} -

-
+ )}
- {buttons && - buttons.length > 0 && - (() => { - const mp = panel as MultiPanel | MultiPanelRequest; - if (mp.select_menu) { - const options = buttons.map((bp) => { - const entry = mp.panels.find((e) => e.panel_id === bp.panel_id); - const emoji = bp.use_custom_emoji - ? bp.emoji_id - ? { - name: resolveEmoteName(bp.emote), - id: bp.emoji_id, - animated: bp.emoji_animated, - } - : undefined - : resolveEmoteName(bp.emote) - ? { name: resolveEmoteName(bp.emote) } - : undefined; - return { - value: String(bp.panel_id), - label: bp.button_label || "Open Ticket", - description: entry?.description, - emoji, - }; - }); - return ( -
- -
- ); - } - return ( -
- {buttons.map((p, index) => ( -
- ); - })()} + {buttonsRow( + buttons && buttons.length > 0 + ? (() => { + const mp = panel as MultiPanel | MultiPanelRequest; + if (mp.select_menu) { + const options = buttons.map((bp) => { + const entry = mp.panels.find((e) => e.panel_id === bp.panel_id); + const emoji = bp.use_custom_emoji + ? bp.emoji_id + ? { + name: resolveEmoteName(bp.emote), + id: bp.emoji_id, + animated: bp.emoji_animated, + } + : undefined + : resolveEmoteName(bp.emote) + ? { name: resolveEmoteName(bp.emote) } + : undefined; + return { + value: String(bp.panel_id), + label: bp.button_label || "Open Ticket", + description: entry?.description, + emoji, + }; + }); + return ( +
+ +
+ ); + } + return ( +
+ {buttons.map((p, index) => ( +
+ ); + })() + : null, + )} ); }; diff --git a/frontend/src/components/component-builder/AddBlockMenu.tsx b/frontend/src/components/component-builder/AddBlockMenu.tsx new file mode 100644 index 00000000..9102353e --- /dev/null +++ b/frontend/src/components/component-builder/AddBlockMenu.tsx @@ -0,0 +1,197 @@ +import { useEffect, useRef, useState, type FC } from "react"; +import { createPortal } from "react-dom"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { + faLayerGroup, + faColumns, + faMinus, + faFont, + faImages, + faLink, + faTicket, +} from "@fortawesome/free-solid-svg-icons"; +import Button from "@/components/Button"; +import { useFloatingDropdown } from "@/hooks/useFloatingDropdown"; +import { + blockLabel, + hasPanelSelectBlock, + type V2Component, + type V2ComponentType, +} from "@/lib/component-tree"; + +interface AddBlockMenuProps { + onAdd: (type: V2ComponentType) => void; + /** Types not offered here, e.g. "container" inside a ContainerEditor's own menu. */ + exclude?: V2ComponentType[]; + /** The full tree this menu is adding into, used to gate "Panel Select" to at most one per tree. */ + tree: V2Component[]; + /** + * Panels not yet placed elsewhere in the tree. Undefined means no "panels" concept applies + * here (single-panel/welcome-message builder), so the "Panel Select" entry is omitted + * entirely rather than merely disabled. + */ + availablePanels?: { panel_id: number; label: string; emoji?: string }[]; + /** + * The multipanel's own "dropdown mode" toggle. Explicitly `false` disables "Panel Select" + * with a dedicated tooltip; omitted (undefined) applies no extra restriction beyond + * `hasPanelSelectBlock`. + */ + selectMenuModeOn?: boolean; +} + +// Button Row sits in Layout rather than Content: like Separator, it's a structural row rather +// than media/text content, and it's the row itself (not "buttons" as freestanding content) that +// an admin is placing into the message. Panel Select joins it for the same reason. +const LAYOUT_TYPES: V2ComponentType[] = [ + "container", + "section", + "separator", + "button_row", + "panel_select", +]; +const CONTENT_TYPES: V2ComponentType[] = ["text_display", "media_gallery"]; + +const ICONS: Record = { + container: faLayerGroup, + section: faColumns, + separator: faMinus, + text_display: faFont, + media_gallery: faImages, + button_row: faLink, + panel_select: faTicket, +}; + +const AddBlockMenu: FC = ({ + onAdd, + exclude = [], + tree, + availablePanels, + selectMenuModeOn, +}) => { + const [isOpen, setIsOpen] = useState(false); + const triggerRef = useRef(null); + const dropdownRef = useRef(null); + + const { position } = useFloatingDropdown({ + isOpen, + triggerRef, + dropdownRef, + onClose: () => setIsOpen(false), + maxHeight: 320, + minWidth: 220, + }); + + const closeAndReturnFocus = () => { + setIsOpen(false); + // Deferred: an add can exhaust the block budget and unmount this menu (trigger included) + // in the same update, so wait for that commit before deciding whether there's still a + // trigger to focus - focusing it synchronously here would focus a node about to be removed. + requestAnimationFrame(() => triggerRef.current?.focus()); + }; + + useEffect(() => { + // The dropdown is portaled to the end of document.body, so a keyboard user tabbing + // from the trigger would otherwise skip straight past it - move focus in explicitly. + if (!isOpen) return; + dropdownRef.current + ?.querySelector('button:not([aria-disabled="true"])') + ?.focus(); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + closeAndReturnFocus(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen]); + + const panelSelectAlreadyPlaced = hasPanelSelectBlock(tree); + + const layoutTypes = LAYOUT_TYPES.filter((t) => { + if (exclude.includes(t)) return false; + if (t === "panel_select" && availablePanels === undefined) return false; + return true; + }); + const contentTypes = CONTENT_TYPES.filter((t) => !exclude.includes(t)); + + const disabledReason = (type: V2ComponentType): string | undefined => { + if (type !== "panel_select") return undefined; + if (selectMenuModeOn === false) return "Enable dropdown mode in Ticket Settings to add this"; + if (panelSelectAlreadyPlaced) return "Only one panel select block is allowed"; + return undefined; + }; + + const renderGroup = (title: string, types: V2ComponentType[]) => { + if (types.length === 0) return null; + return ( +
+

{title}

+ {types.map((type) => { + const reason = disabledReason(type); + return ( + + ); + })} +
+ ); + }; + + return ( + <> + + {isOpen && + createPortal( +
+ {renderGroup("Layout", layoutTypes)} + {renderGroup("Content", contentTypes)} +
, + document.body, + )} + + ); +}; + +export default AddBlockMenu; diff --git a/frontend/src/components/component-builder/AutoAppendedTag.tsx b/frontend/src/components/component-builder/AutoAppendedTag.tsx new file mode 100644 index 00000000..ffc4906d --- /dev/null +++ b/frontend/src/components/component-builder/AutoAppendedTag.tsx @@ -0,0 +1,18 @@ +import type { FC, ReactNode } from "react"; + +interface AutoAppendedTagProps { + children: ReactNode; + className?: string; +} + +/** Wraps a system-injected element (e.g. the open-ticket button) in the Components v2 preview. */ +const AutoAppendedTag: FC = ({ children, className = "" }) => ( +
+ + Added automatically + + {children} +
+); + +export default AutoAppendedTag; diff --git a/frontend/src/components/component-builder/BlockList.tsx b/frontend/src/components/component-builder/BlockList.tsx new file mode 100644 index 00000000..7e3ef5dd --- /dev/null +++ b/frontend/src/components/component-builder/BlockList.tsx @@ -0,0 +1,296 @@ +import type { FC } from "react"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { + faLayerGroup, + faColumns, + faMinus, + faFont, + faImages, + faLink, + faTicket, +} from "@fortawesome/free-solid-svg-icons"; +import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import type { GuildEmoji } from "@/types"; +import { + addBlockAtTop, + addBlockToContainer, + blockLabel, + countBlocks, + createEmptyBlock, + getTopLevelContainers, + moveBlock, + moveBlockIntoContainer, + moveBlockOutOfContainer, + removeBlock, + updateBlock, + type V2Component, + type V2ComponentType, +} from "@/lib/component-tree"; +import BlockShell from "./BlockShell"; +import AddBlockMenu from "./AddBlockMenu"; +import ContainerEditor from "./ContainerEditor"; +import SectionEditor from "./SectionEditor"; +import TextDisplayEditor from "./TextDisplayEditor"; +import MediaGalleryEditor from "./MediaGalleryEditor"; +import SeparatorEditor from "./SeparatorEditor"; +import ButtonRowEditor from "./ButtonRowEditor"; + +export const BLOCK_ICONS: Record = { + container: faLayerGroup, + section: faColumns, + separator: faMinus, + text_display: faFont, + media_gallery: faImages, + button_row: faLink, + panel_select: faTicket, +}; + +type AvailablePanel = { panel_id: number; label: string; emoji?: string }; + +/** + * Builds the panel list a single block's picker should see: the globally-available (unplaced) + * panels, plus whichever panel(s) this specific block already has selected. `getPlacedPanelIds` + * (computed once for the whole tree) has no way to know that a placement "occupies" itself, so a + * button/accessory that already points at panel 7 finds panel 7 missing from `available` purely + * because it is the one holding it. Without adding it back in here, that control's `Select` would + * render as unset even though `panel_id` is genuinely still populated in the data. + * + * Looks up the missing panel(s) in `allPanels` (the full, unfiltered list) rather than fabricating + * an entry, so the label/emoji shown match the guild's real panel data. If a panel_id isn't found + * in `allPanels` either - an inconsistent-state edge case - it's silently skipped rather than + * crashing or adding a placeholder. + */ +const withOwnSelection = ( + available: AvailablePanel[], + allPanels: AvailablePanel[], + ownPanelIds: number[], +): AvailablePanel[] => { + const uniqueOwnPanelIds = [...new Set(ownPanelIds)]; + const missing = uniqueOwnPanelIds.filter((id) => !available.some((p) => p.panel_id === id)); + if (missing.length === 0) return available; + const additions = missing + .map((id) => allPanels.find((p) => p.panel_id === id)) + .filter((p): p is AvailablePanel => p !== undefined); + return additions.length > 0 ? [...available, ...additions] : available; +}; + +/** + * A panel_select block has no fields of its own - it always binds every currently-unplaced + * panel, which is exactly what `availablePanels` already represents by the caller's contract + * (see the doc comment on the `availablePanels` prop below). Renders a single-line summary of + * which panels that currently is, matching the truncate + full-list-in-title pattern + * MultiSelect.tsx uses for its own "list of N items, may overflow" chip row. + */ +const PanelSelectSummary: FC<{ availablePanels?: AvailablePanel[] }> = ({ availablePanels }) => { + const names = (availablePanels ?? []).map((p) => p.label); + if (names.length === 0) { + return ( +

+ No panels remaining - place a panel button elsewhere to remove one from here, or delete this + block. +

+ ); + } + const fullList = names.join(", "); + return ( +

+ Contains: {fullList} +

+ ); +}; + +interface BlockListProps { + blocks: V2Component[]; + tree: V2Component[]; + onTreeChange: (next: V2Component[]) => void; + guildEmojis: GuildEmoji[]; + nested: boolean; + containerId?: string; + containerLabel?: string; + onAnnounce: (message: string) => void; + disabled?: boolean; + /** Overall Discord component budget (30 minus whatever the backend reserves). */ + budgetMax: number; + /** Top-level slot budget (10 minus reserved), only enforced for the non-nested list. */ + topLevelMax: number; + /** + * Panels not yet placed elsewhere in the tree (per `getPlacedPanelIds`), threaded down to + * every panel-placement control the same way `guildEmojis` already is. Undefined means this + * builder is being used somewhere with no "panels" concept at all (single-panel or + * welcome-message message), in which case no panel-placement UI renders anywhere below. + */ + availablePanels?: AvailablePanel[]; + /** + * The full, unfiltered panel list for this multipanel (same undefined-means-no-panels-concept + * convention as `availablePanels`). Needed alongside `availablePanels` so a block that already + * holds a placement can look up that panel's label/emoji even though it's excluded from the + * globally-available list for being placed by that very block - see `withOwnSelection`. + */ + allPanels?: AvailablePanel[]; + /** The multipanel's own "dropdown mode" toggle - see AddBlockMenu's prop of the same name. */ + selectMenuModeOn?: boolean; +} + +const BlockList: FC = ({ + blocks, + tree, + onTreeChange, + guildEmojis, + nested, + containerId, + containerLabel, + onAnnounce, + disabled, + budgetMax, + topLevelMax, + availablePanels, + allPanels, + selectMenuModeOn, +}) => { + const topLevelContainers = nested ? [] : getTopLevelContainers(tree); + const budgetExhausted = countBlocks(tree) >= budgetMax; + const topLevelExhausted = !nested && blocks.length >= topLevelMax; + const canAddMore = !disabled && !budgetExhausted && !topLevelExhausted; + + return ( +
+ block.id)} + strategy={verticalListSortingStrategy} + > + {blocks.map((block, index) => { + const canMoveUp = index > 0; + const canMoveDown = index < blocks.length - 1; + const label = blockLabel(block.type); + // Distinguishes same-type blocks for assistive tech (e.g. two Text Displays both + // named "Text Display" would otherwise share identical control names). + const shellLabel = + blocks.length > 1 ? `${label} (${index + 1} of ${blocks.length})` : label; + + const handleMove = (direction: "up" | "down") => { + onTreeChange(moveBlock(tree, block.id, direction)); + const newPosition = direction === "up" ? index : index + 2; + onAnnounce( + `Moved ${label} ${direction} to position ${newPosition} of ${blocks.length}`, + ); + }; + + const handleMoveIntoContainer = (targetContainerId: string) => { + const target = topLevelContainers.find((c) => c.id === targetContainerId); + onTreeChange(moveBlockIntoContainer(tree, block.id, targetContainerId)); + onAnnounce(`Moved ${label} into ${target?.label ?? "container"}`); + }; + + const handleMoveOutOfContainer = () => { + onTreeChange(moveBlockOutOfContainer(tree, block.id)); + onAnnounce(`Moved ${label} out of ${containerLabel ?? "container"} to top level`); + }; + + const handleDelete = () => { + onTreeChange(removeBlock(tree, block.id)); + onAnnounce(`Deleted ${label}`); + }; + + const handleUpdate = (updated: V2Component) => + onTreeChange(updateBlock(tree, block.id, () => updated)); + + return ( + handleMove("up")} + onMoveDown={() => handleMove("down")} + moveIntoContainerTargets={ + !nested && block.type !== "container" ? topLevelContainers : undefined + } + onMoveIntoContainer={handleMoveIntoContainer} + canMoveOutOfContainer={nested} + onMoveOutOfContainer={handleMoveOutOfContainer} + onDelete={handleDelete} + nested={nested} + disabled={disabled} + > + {block.type === "container" && ( + + )} + {block.type === "section" && ( + + )} + {block.type === "text_display" && ( + + )} + {block.type === "media_gallery" && ( + + )} + {block.type === "separator" && ( + + )} + {block.type === "button_row" && ( + b.kind === "panel").map((b) => b.panel_id), + ) + : availablePanels + } + /> + )} + {block.type === "panel_select" && ( + + )} + + ); + })} + + {canAddMore && ( + { + const newBlock = createEmptyBlock(type); + onTreeChange( + nested && containerId + ? addBlockToContainer(tree, containerId, newBlock) + : addBlockAtTop(tree, newBlock), + ); + onAnnounce(`Added ${blockLabel(type)}${containerLabel ? ` to ${containerLabel}` : ""}`); + }} + /> + )} +
+ ); +}; + +export default BlockList; diff --git a/frontend/src/components/component-builder/BlockShell.tsx b/frontend/src/components/component-builder/BlockShell.tsx new file mode 100644 index 00000000..3257c447 --- /dev/null +++ b/frontend/src/components/component-builder/BlockShell.tsx @@ -0,0 +1,168 @@ +import type { CSSProperties, FC, ReactNode } from "react"; +import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faChevronUp, + faChevronDown, + faTrash, + faGripVertical, +} from "@fortawesome/free-solid-svg-icons"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import Button from "@/components/Button"; + +interface MoveTarget { + id: string; + label: string; +} + +interface BlockShellProps { + /** Stable id of the block this shell renders, used as the dnd-kit sortable item id. */ + id: string; + label: string; + icon: IconDefinition; + canMoveUp: boolean; + canMoveDown: boolean; + onMoveUp: () => void; + onMoveDown: () => void; + moveIntoContainerTargets?: MoveTarget[]; + onMoveIntoContainer?: (containerId: string) => void; + canMoveOutOfContainer: boolean; + onMoveOutOfContainer?: () => void; + onDelete: () => void; + /** Visual depth cue for a block rendered inside a Container's block list. */ + nested?: boolean; + disabled?: boolean; + children: ReactNode; +} + +const BlockShell: FC = ({ + id, + label, + icon, + canMoveUp, + canMoveDown, + onMoveUp, + onMoveDown, + moveIntoContainerTargets, + onMoveIntoContainer, + canMoveOutOfContainer, + onMoveOutOfContainer, + onDelete, + nested, + disabled, + children, +}) => { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id, + disabled, + }); + + // The actual floating drag preview is rendered by ComponentTreeBuilder's DragOverlay, so this + // card only ever needs to show where it would land if dropped now (a plain vertical + // translate) - dnd-kit skips its own pointer-following transform on the source item whenever + // a DragOverlay is present. + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+
+
+ +
+
+ {canMoveOutOfContainer && onMoveOutOfContainer && ( + + )} + {!!moveIntoContainerTargets?.length && onMoveIntoContainer && ( + + )} + + + +
+
+ {children} +
+ ); +}; + +export default BlockShell; diff --git a/frontend/src/components/component-builder/BuildPreviewTabs.tsx b/frontend/src/components/component-builder/BuildPreviewTabs.tsx new file mode 100644 index 00000000..5e71c2f3 --- /dev/null +++ b/frontend/src/components/component-builder/BuildPreviewTabs.tsx @@ -0,0 +1,74 @@ +import { useId, useState, type FC, type ReactNode } from "react"; + +interface BuildPreviewTabsProps { + build: ReactNode; + preview: ReactNode; + /** Extra class names for the tab-switch row, e.g. to span both columns of a parent grid. */ + className?: string; +} + +/** + * Two-tab Build/Preview switch shown only below `md`, where the builder and its live preview + * can't sit side by side. Renders as three siblings (tabs, build pane, preview pane) rather than + * a single wrapper, so a parent `grid-cols-1 md:grid-cols-2` still lays the build and preview + * panes out side by side from `md` up - Classic mode's always-stacked mobile layout is untouched. + */ +const BuildPreviewTabs: FC = ({ build, preview, className = "" }) => { + const [activeTab, setActiveTab] = useState<"build" | "preview">("build"); + const uid = useId(); + const buildTabId = `${uid}-build-tab`; + const previewTabId = `${uid}-preview-tab`; + const buildPanelId = `${uid}-build-panel`; + const previewPanelId = `${uid}-preview-panel`; + + return ( + <> +
+ + +
+
+ {build} +
+
+ {preview} +
+ + ); +}; + +export default BuildPreviewTabs; diff --git a/frontend/src/components/component-builder/ButtonRowEditor.tsx b/frontend/src/components/component-builder/ButtonRowEditor.tsx new file mode 100644 index 00000000..6c5e3f1a --- /dev/null +++ b/frontend/src/components/component-builder/ButtonRowEditor.tsx @@ -0,0 +1,181 @@ +import type { FC } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import Button from "@/components/Button"; +import Select from "@/components/Select"; +import TextInput from "@/components/TextInput"; +import { EMBED_LIMITS } from "@/constants/embedLimits"; +import { isSafeUrl } from "@/lib/url"; +import type { V2ButtonRow, V2ButtonRowButton } from "@/lib/component-tree"; +import PanelButtonTag from "./PanelButtonTag"; + +const MAX_BUTTONS = 5; + +interface ButtonRowEditorProps { + value: V2ButtonRow; + onChange: (updated: V2ButtonRow) => void; + /** + * Panels not yet placed elsewhere in the tree (plus, for a row already pointing at a panel, + * that panel's own entry - the caller is responsible for including it). Undefined means this + * builder is being used somewhere with no "panels" concept at all (single-panel or + * welcome-message message), in which case none of the panel-placement UI below renders and + * this behaves exactly as a link-only button row. + */ + availablePanels?: { panel_id: number; label: string; emoji?: string }[]; +} + +const ButtonRowEditor: FC = ({ value, onChange, availablePanels }) => { + const updateLinkField = (index: number, update: Partial<{ label: string; url: string }>) => { + const buttons = value.buttons.map((b, i) => { + if (i !== index || b.kind === "panel") return b; + return { ...b, ...update }; + }); + onChange({ ...value, buttons }); + }; + + const setPanelId = (index: number, panelId: number) => { + const buttons = value.buttons.map((b, i) => + i === index && b.kind === "panel" ? { ...b, panel_id: panelId } : b, + ); + onChange({ ...value, buttons }); + }; + + const setButtonMode = (index: number, kind: "link" | "panel") => { + const buttons = value.buttons.map((b, i): V2ButtonRowButton => { + if (i !== index) return b; + const currentlyPanel = b.kind === "panel"; + if (kind === "panel") { + if (currentlyPanel) return b; + const panelId = availablePanels?.[0]?.panel_id; + // The "Ticket panel" toggle is disabled whenever availablePanels is empty, so this + // should never be reached with nothing to default to - guard anyway rather than + // inventing a hidden panel_id such as 0. + if (panelId === undefined) return b; + return { id: b.id, kind: "panel", panel_id: panelId }; + } + if (!currentlyPanel) return b; + return { id: b.id, kind: "link", label: "", url: "" }; + }); + onChange({ ...value, buttons }); + }; + + const removeButton = (index: number) => { + onChange({ ...value, buttons: value.buttons.filter((_, i) => i !== index) }); + }; + + const addButton = () => { + if (value.buttons.length >= MAX_BUTTONS) return; + onChange({ + ...value, + buttons: [...value.buttons, { id: crypto.randomUUID(), label: "", url: "" }], + }); + }; + + return ( +
+ {value.buttons.map((b, i) => { + const isPanelMode = b.kind === "panel"; + + return ( +
+
+ {availablePanels !== undefined && ( +
+ + +
+ )} + + {isPanelMode ? ( +
+ onChange(option.mode)} + /> + {locked && ( + + + )} +
+
+
+
+ {option.title} + {selected && !locked && ( +
+

{option.description}

+
+ + ); + })} +
+ + ); +}; + +export default MessageModeToggle; diff --git a/frontend/src/components/component-builder/PanelButtonTag.tsx b/frontend/src/components/component-builder/PanelButtonTag.tsx new file mode 100644 index 00000000..347ebbed --- /dev/null +++ b/frontend/src/components/component-builder/PanelButtonTag.tsx @@ -0,0 +1,25 @@ +import type { FC } from "react"; + +interface PanelButtonTagProps { + /** Resolved panel display name, shown alongside the static message when known. */ + label?: string; + className?: string; +} + +/** + * Small inline badge marking a button/accessory as bound to a ticket panel, editable from Panel + * Customization rather than here. Deliberately not a reuse of AutoAppendedTag: that component + * wraps its children in `opacity-70 pointer-events-none`, which is right for a genuinely + * read-only system-injected preview but would wrongly disable the still-interactive panel + * picker/remove controls sitting next to this tag. This is a plain label, not a wrapper - it + * renders no children of its own. + */ +const PanelButtonTag: FC = ({ label, className = "" }) => ( + + {label ? `${label} - edited in Panel Customization` : "Edited in Panel Customization"} + +); + +export default PanelButtonTag; diff --git a/frontend/src/components/component-builder/SectionEditor.tsx b/frontend/src/components/component-builder/SectionEditor.tsx new file mode 100644 index 00000000..0318768a --- /dev/null +++ b/frontend/src/components/component-builder/SectionEditor.tsx @@ -0,0 +1,247 @@ +import type { FC } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus, faTrash, faImage, faLink, faTicket } from "@fortawesome/free-solid-svg-icons"; +import Button from "@/components/Button"; +import Select from "@/components/Select"; +import TextInput from "@/components/TextInput"; +import Textarea from "@/components/Textarea"; +import Slider from "@/components/Slider"; +import { EMBED_LIMITS } from "@/constants/embedLimits"; +import { isSafeUrl } from "@/lib/url"; +import type { GuildEmoji } from "@/types"; +import type { V2Section, V2TextChild } from "@/lib/component-tree"; +import PanelButtonTag from "./PanelButtonTag"; + +const MAX_TEXTS = 3; + +interface SectionEditorProps { + value: V2Section; + onChange: (updated: V2Section) => void; + guildEmojis?: GuildEmoji[]; + /** + * Panels not yet placed elsewhere in the tree (plus, if this section's accessory already + * points at a panel, that panel's own entry - the caller's responsibility). Undefined means + * no "panels" concept applies here (single-panel/welcome-message builder), so the "Panel + * button" accessory option is omitted entirely. + */ + availablePanels?: { panel_id: number; label: string; emoji?: string }[]; +} + +const SectionEditor: FC = ({ value, onChange, availablePanels }) => { + const updateText = (index: number, content: string) => { + const components = value.components.map((c, i) => (i === index ? { ...c, content } : c)); + onChange({ ...value, components }); + }; + + const removeText = (index: number) => { + onChange({ ...value, components: value.components.filter((_, i) => i !== index) }); + }; + + const addText = () => { + if (value.components.length >= MAX_TEXTS) return; + const newChild: V2TextChild = { id: crypto.randomUUID(), content: "" }; + onChange({ ...value, components: [...value.components, newChild] }); + }; + + const thumbnailAccessory = value.accessory?.kind === "thumbnail" ? value.accessory : undefined; + const linkButtonAccessory = value.accessory?.kind === "link_button" ? value.accessory : undefined; + const panelButtonAccessory = + value.accessory?.kind === "panel_button" ? value.accessory : undefined; + + return ( +
+ {value.components.map((child, i) => ( +
+