From fdde9724c403eadadb764c8e20acd97a68f04d1f Mon Sep 17 00:00:00 2001 From: Ben Hall Date: Sun, 6 Sep 2026 17:48:55 +0100 Subject: [PATCH 1/5] feat: message performance Signed-off-by: Ben Hall --- bot/logic/welcomemessage.go | 153 ++++++++++++++++++++++++++++++++++++ go.mod | 2 +- restwrapper.go | 2 +- 3 files changed, 155 insertions(+), 2 deletions(-) diff --git a/bot/logic/welcomemessage.go b/bot/logic/welcomemessage.go index 32a2ed4e..f4d8de1c 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -2,6 +2,7 @@ package logic import ( "context" + "encoding/json" "fmt" "regexp" "sort" @@ -10,11 +11,13 @@ import ( "sync" "time" + "github.com/TicketsBot-cloud/common/featureflags" "github.com/TicketsBot-cloud/common/model" "github.com/TicketsBot-cloud/common/premium" "github.com/TicketsBot-cloud/common/sentry" "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/guild/emoji" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/gdl/rest" @@ -40,6 +43,10 @@ func SendWelcomeMessage( // Only custom integration placeholders for now - prevent making duplicate requests additionalPlaceholders map[string]string, ) (uint64, error) { + if panel != nil && panel.WelcomeMessageUsesComponentsV2 && utils.FeatureFlags.IsEnabled(ctx, "202608_COMPONENTS_V2_BUILDER", featureflags.ForGuild(ticket.GuildId)) { + return sendWelcomeMessageV2(ctx, cmd, ticket, panel, formData, additionalPlaceholders) + } + // Build embeds welcomeMessageEmbed, err := BuildWelcomeMessageEmbed(ctx, cmd, ticket, subject, panel, additionalPlaceholders) if err != nil { @@ -122,6 +129,152 @@ func SendWelcomeMessage( return msg.Id, nil } +const ( + componentsV2MaxTopLevel = 10 + componentsV2MaxTotal = 30 +) + +// sendWelcomeMessageV2 sends a welcome message built from a panel's Components V2 tree, +// rather than the classic embed-based welcome message. +func sendWelcomeMessageV2( + ctx context.Context, + cmd registry.CommandContext, + ticket database.Ticket, + panel *database.Panel, + formData map[database.FormInput]string, + // Only custom integration placeholders for now - prevent making duplicate requests + additionalPlaceholders map[string]string, +) (uint64, error) { + if ticket.ChannelId == nil { + return 0, fmt.Errorf("channel is nil") + } + + if panel.WelcomeMessageComponents == nil { + return 0, fmt.Errorf("panel %d has components v2 welcome message enabled but no components stored", panel.PanelId) + } + + var tree []component.Component + if err := json.Unmarshal([]byte(*panel.WelcomeMessageComponents), &tree); err != nil { + return 0, fmt.Errorf("unmarshal welcome message components: %w", err) + } + + var buttons []component.Component + if !panel.HideCloseButton { + buttons = append(buttons, component.BuildButton(component.Button{ + Label: cmd.GetMessage(i18n.TitleClose), + CustomId: "close", + Style: component.ButtonStyleDanger, + Emoji: &emoji.Emoji{Name: "๐Ÿ”’"}, + })) + } + + if !panel.HideCloseWithReasonButton { + buttons = append(buttons, component.BuildButton(component.Button{ + Label: cmd.GetMessage(i18n.TitleCloseWithReason), + CustomId: "close_with_reason", + Style: component.ButtonStyleDanger, + Emoji: &emoji.Emoji{Name: "๐Ÿ”’"}, + })) + } + + if !panel.HideClaimButton && !ticket.IsThread { + buttons = append(buttons, component.BuildButton(component.Button{ + Label: cmd.GetMessage(i18n.TitleClaim), + CustomId: "claim", + Style: component.ButtonStyleSuccess, + Emoji: &emoji.Emoji{Name: "๐Ÿ™‹โ€โ™‚๏ธ"}, + })) + } + + // Work out how much of Discord's 10 top-level / 30 total component budget is + // left over for form-answer content, after the panel's own tree and the button + // row. This can only be known at send time, since the panel's tree is opaque + // JSON authored in the dashboard. + usedTopLevel := len(tree) + usedTotal := countComponents(tree) + if len(buttons) > 0 { + usedTopLevel++ // the action row itself + usedTotal += 1 + len(buttons) // the action row plus its buttons + } + + budget := componentsV2MaxTopLevel - usedTopLevel + if remaining := componentsV2MaxTotal - usedTotal; remaining < budget { + budget = remaining + } + + fields := getFormDataFields(formData) + + var formComponents []component.Component + truncated := false + for _, field := range fields { + if budget <= 0 { + truncated = true + break + } + + formComponents = append(formComponents, component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("**%s**\n%s", field.Name, utils.EscapeMarkdown(field.Value)), + })) + budget-- + } + + if truncated { + sentry.Log("welcome message v2: form answers truncated, component budget exceeded", map[string]interface{}{ + "guild_id": ticket.GuildId, + "ticket_id": ticket.Id, + "panel_id": panel.PanelId, + }) + } + + finalTree := make([]component.Component, 0, len(tree)+len(formComponents)+1) + finalTree = append(finalTree, tree...) + finalTree = append(finalTree, formComponents...) + if len(buttons) > 0 { + finalTree = append(finalTree, component.BuildActionRow(buttons...)) + } + + // formComponents' TextDisplay content is attacker-controlled (the ticket-opener's own + // form answers), and unlike embed fields, Components V2 text content is parsed for + // mentions. AllowedMentions is left as its zero value deliberately: CreateMessageData + // has no `omitempty` on that field, so it is always sent as an explicit empty object + // (`{"replied_user":false}`, no "parse"/"roles"/"users"), which Discord treats as + // suppressing every mention type - the same protection tag.go/closerequest.go apply + // explicitly elsewhere in this codebase for other user-controlled message content. + data := rest.CreateMessageData{ + Flags: message.SumFlags(message.FlagComponentsV2), + Components: finalTree, + } + + msg, err := cmd.Worker().CreateMessageComplex(*ticket.ChannelId, data) + if err != nil { + return 0, err + } + + return msg.Id, nil +} + +// countComponents recursively counts a component tree, including components nested +// within action rows, containers and sections, to approximate Discord's 30-component +// total limit. +func countComponents(components []component.Component) int { + count := len(components) + for _, c := range components { + switch data := c.ComponentData.(type) { + case component.ActionRow: + count += countComponents(data.Components) + case component.Container: + count += countComponents(data.Components) + case component.Section: + count += countComponents(data.Components) + if data.Accessory.ComponentData != nil { + count++ + } + } + } + + return count +} + func BuildWelcomeMessageEmbed( ctx context.Context, cmd registry.CommandContext, diff --git a/go.mod b/go.mod index 3bcf40db..1a85128b 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/TicketsBot-cloud/worker go 1.25.0 -// replace github.com/TicketsBot-cloud/database => ../database +replace github.com/TicketsBot-cloud/database => ../database // replace github.com/TicketsBot-cloud/common => ../common diff --git a/restwrapper.go b/restwrapper.go index a8118a6d..14cfb6b6 100644 --- a/restwrapper.go +++ b/restwrapper.go @@ -42,7 +42,7 @@ func (ctx *Context) GetChannel(channelId uint64) (channel.Channel, error) { func (ctx *Context) ModifyChannel(reqCtx context.Context, channelId uint64, data rest.ModifyChannelData) (channel.Channel, error) { channel, err := rest.ModifyChannel(reqCtx, ctx.Token, ctx.RateLimiter, channelId, data) - if ctx.Cache.Options().Channels && err != nil { + if ctx.Cache.Options().Channels && err == nil { go ctx.Cache.StoreChannel(context.Background(), channel) } From 41a907402ce95bd8d4e8f13d0d9238a631c0fe17 Mon Sep 17 00:00:00 2001 From: Ben Hall Date: Wed, 14 Oct 2026 17:07:28 +0100 Subject: [PATCH 2/5] fixes based on feedback Signed-off-by: Ben Hall --- bot/button/handlers/addadmin.go | 2 +- bot/button/handlers/addsupport.go | 2 +- bot/command/context/mentionabletype.go | 8 + bot/command/impl/settings/removeadmin.go | 2 +- bot/command/impl/settings/removesupport.go | 2 +- bot/command/impl/tickets/oncall.go | 2 +- bot/command/impl/tickets/open.go | 2 +- bot/command/impl/tickets/switchpanel.go | 4 +- bot/command/messageresponse.go | 14 +- bot/command/messageresponse_test.go | 36 +++ bot/logic/claim.go | 125 +++++---- bot/logic/welcomemessage.go | 226 ++++++++++------ bot/logic/welcomemessage_v2_test.go | 290 +++++++++++++++++++++ go.mod | 2 +- go.sum | 2 + 15 files changed, 574 insertions(+), 145 deletions(-) create mode 100644 bot/logic/welcomemessage_v2_test.go diff --git a/bot/button/handlers/addadmin.go b/bot/button/handlers/addadmin.go index 1d283ee4..c493dcd9 100644 --- a/bot/button/handlers/addadmin.go +++ b/bot/button/handlers/addadmin.go @@ -159,7 +159,7 @@ func (h *AddAdminHandler) Execute(ctx *context.ButtonContext) { auditReason = fmt.Sprintf("Added admin member/role by %s", member.User.Username) } - panels, _ := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) + panels, _ := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, ctx.GuildId()) seen := make(map[uint64]struct{}) for _, p := range panels { if p.TicketNotificationChannel == nil { diff --git a/bot/button/handlers/addsupport.go b/bot/button/handlers/addsupport.go index ab90e3de..c08805f8 100644 --- a/bot/button/handlers/addsupport.go +++ b/bot/button/handlers/addsupport.go @@ -157,7 +157,7 @@ func updateChannelPermissions(ctx cmdregistry.CommandContext, id uint64, mention return } - panels, err := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) + panels, err := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, ctx.GuildId()) if err != nil { ctx.HandleError(err) return diff --git a/bot/command/context/mentionabletype.go b/bot/command/context/mentionabletype.go index 0b6557f0..880a8144 100644 --- a/bot/command/context/mentionabletype.go +++ b/bot/command/context/mentionabletype.go @@ -13,6 +13,14 @@ const ( MentionableTypeRole ) +func (m MentionableType) String() string { + if m == MentionableTypeRole { + return "role" + } + + return "user" +} + func (m MentionableType) OverwriteType() channel.PermissionOverwriteType { switch m { case MentionableTypeUser: diff --git a/bot/command/impl/settings/removeadmin.go b/bot/command/impl/settings/removeadmin.go index 0945149b..1d242797 100644 --- a/bot/command/impl/settings/removeadmin.go +++ b/bot/command/impl/settings/removeadmin.go @@ -134,7 +134,7 @@ func (c RemoveAdminCommand) Execute(ctx registry.CommandContext, id uint64) { auditReason = fmt.Sprintf("Removed admin member/role by %s", member.User.Username) } - panels, _ := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) + panels, _ := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, ctx.GuildId()) seen := make(map[uint64]struct{}) for _, p := range panels { if p.TicketNotificationChannel == nil { diff --git a/bot/command/impl/settings/removesupport.go b/bot/command/impl/settings/removesupport.go index ac007795..e089a746 100644 --- a/bot/command/impl/settings/removesupport.go +++ b/bot/command/impl/settings/removesupport.go @@ -145,7 +145,7 @@ func (c RemoveSupportCommand) Execute(ctx registry.CommandContext, id uint64) { auditReason = fmt.Sprintf("Removed support member/role by %s", member.User.Username) } - panels, _ := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) + panels, _ := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, ctx.GuildId()) seen := make(map[uint64]struct{}) for _, p := range panels { if p.TicketNotificationChannel == nil { diff --git a/bot/command/impl/tickets/oncall.go b/bot/command/impl/tickets/oncall.go index 3f7ca3f7..013b5d2e 100644 --- a/bot/command/impl/tickets/oncall.go +++ b/bot/command/impl/tickets/oncall.go @@ -39,7 +39,7 @@ func (c OnCallCommand) GetExecutor() interface{} { } func (OnCallCommand) Execute(ctx registry.CommandContext) { - panels, err := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) + panels, err := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, ctx.GuildId()) if err != nil { ctx.HandleError(err) return diff --git a/bot/command/impl/tickets/open.go b/bot/command/impl/tickets/open.go index 3601c6bb..19f113eb 100644 --- a/bot/command/impl/tickets/open.go +++ b/bot/command/impl/tickets/open.go @@ -67,7 +67,7 @@ func (OpenCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCo ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) defer cancel() - allPanels, err := dbclient.Client.Panel.GetByGuild(ctx, data.GuildId.Value) + allPanels, err := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, data.GuildId.Value) if err != nil { sentry.Error(err) return nil diff --git a/bot/command/impl/tickets/switchpanel.go b/bot/command/impl/tickets/switchpanel.go index ca043f79..c02d02d1 100644 --- a/bot/command/impl/tickets/switchpanel.go +++ b/bot/command/impl/tickets/switchpanel.go @@ -180,7 +180,7 @@ func (SwitchPanelCommand) Execute(ctx *cmdcontext.SlashCommandContext, panelId i msg, err := ctx.Worker().GetChannelMessage(*ticket.ChannelId, *ticket.WelcomeMessageId) // Error is likely to be due to message being deleted, we want to continue further even if it is - if err == nil { + if err == nil && logic.ShouldRewriteClassicWelcome(msg, &newPanel) { var subject string embeds := utils.PtrElems(msg.Embeds) // TODO: Fix types @@ -340,7 +340,7 @@ func (SwitchPanelCommand) AutoCompleteHandler(data interaction.ApplicationComman ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) // TODO: Propagate context defer cancel() - panels, err := dbclient.Client.Panel.GetByGuild(ctx, data.GuildId.Value) + panels, err := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, data.GuildId.Value) if err != nil { sentry.Error(err) // TODO: Context return nil diff --git a/bot/command/messageresponse.go b/bot/command/messageresponse.go index 0010ecd0..815d532e 100644 --- a/bot/command/messageresponse.go +++ b/bot/command/messageresponse.go @@ -95,12 +95,20 @@ func (r *MessageResponse) IntoCreateMessageData() rest.CreateMessageData { } func (r *MessageResponse) IntoEditMessageData() rest.EditMessageData { - return rest.EditMessageData{ - Content: r.Content, - Embeds: r.Embeds, + data := rest.EditMessageData{ Flags: r.Flags, Components: r.Components, } + + // V2 messages cannot carry content, and Discord requires embeds to be an empty array. + if r.isComponentsV2() { + data.Embeds = []*embed.Embed{} + } else { + data.Content = r.Content + data.Embeds = r.Embeds + } + + return data } func (r *MessageResponse) IntoWebhookBody() rest.WebhookBody { diff --git a/bot/command/messageresponse_test.go b/bot/command/messageresponse_test.go index 9079e282..47423866 100644 --- a/bot/command/messageresponse_test.go +++ b/bot/command/messageresponse_test.go @@ -71,3 +71,39 @@ func TestMessageResponseEditsOmitContentAndEmbedsUnderComponentsV2(t *testing.T) }) } } + +func TestMessageResponseIntoEditMessageData(t *testing.T) { + components := []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "hello"})} + embeds := []*embed.Embed{embed.NewEmbed().SetTitle("legacy")} + + tests := []struct { + name string + flags uint + wantLegacy bool + }{ + {"components v2", message.SumFlags(message.FlagComponentsV2), false}, + {"ephemeral components v2", message.SumFlags(message.FlagEphemeral, message.FlagComponentsV2), false}, + {"legacy", 0, true}, + {"ephemeral legacy", message.SumFlags(message.FlagEphemeral), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := MessageResponse{Content: "some text", Embeds: embeds, Components: components, Flags: tt.flags} + data := res.IntoEditMessageData() + + require.Len(t, data.Components, 1) + require.Equal(t, tt.flags, data.Flags) + + if tt.wantLegacy { + require.Equal(t, "some text", data.Content) + require.Len(t, data.Embeds, 1) + return + } + + require.Empty(t, data.Content) + require.NotNil(t, data.Embeds, "Discord requires an empty array, not null, on a V2 message") + require.Empty(t, data.Embeds) + }) + } +} diff --git a/bot/logic/claim.go b/bot/logic/claim.go index bf29e41f..703bb653 100644 --- a/bot/logic/claim.go +++ b/bot/logic/claim.go @@ -8,6 +8,7 @@ import ( "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/channel" "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/permission" @@ -686,68 +687,86 @@ func UpdateWelcomeMessageClaimButton(ctx context.Context, worker *worker.Context return nil } - // Find and update the button - updated := false - for i, comp := range msg.Components { - if comp.Type == component.ComponentActionRow { - row := comp.ComponentData.(component.ActionRow) - - for j, btnComp := range row.Components { - if btnComp.Type == component.ComponentButton { - btn := btnComp.ComponentData.(component.Button) - - if claimed && btn.CustomId == "claim" { - // Replace claim button with unclaim button - row.Components[j] = component.BuildButton(component.Button{ - Label: cmd.GetMessage(i18n.TitleUnclaim), - CustomId: "unclaim", - Style: component.ButtonStyleSecondary, - Emoji: &emoji.Emoji{Name: "๐Ÿ™‹โ€โ™‚๏ธ"}, - }) - updated = true - break - } else if !claimed && btn.CustomId == "unclaim" { - // Replace unclaim button with claim button - row.Components[j] = component.BuildButton(component.Button{ - Label: cmd.GetMessage(i18n.TitleClaim), - CustomId: "claim", - Style: component.ButtonStyleSuccess, - Emoji: &emoji.Emoji{Name: "๐Ÿ™‹โ€โ™‚๏ธ"}, - }) - updated = true - break - } - } - } - - if updated { - msg.Components[i] = component.Component{ - Type: component.ComponentActionRow, - ComponentData: row, - } - break - } - } + replacement := component.BuildButton(component.Button{ + Label: cmd.GetMessage(i18n.TitleClaim), + CustomId: "claim", + Style: component.ButtonStyleSuccess, + Emoji: &emoji.Emoji{Name: "๐Ÿ™‹โ€โ™‚๏ธ"}, + }) + fromId := "unclaim" + if claimed { + replacement = component.BuildButton(component.Button{ + Label: cmd.GetMessage(i18n.TitleUnclaim), + CustomId: "unclaim", + Style: component.ButtonStyleSecondary, + Emoji: &emoji.Emoji{Name: "๐Ÿ™‹โ€โ™‚๏ธ"}, + }) + fromId = "claim" } - // If no button was found to update, nothing to do - if !updated { + if !replaceButton(msg.Components, fromId, replacement) { return nil } - // Convert embeds to pointers - embeds := make([]*embed.Embed, len(msg.Embeds)) - for i := range msg.Embeds { - embeds[i] = &msg.Embeds[i] + editData := welcomeMessageEditData(msg) + + _, err = worker.EditMessage(*ticket.ChannelId, *ticket.WelcomeMessageId, editData) + return err +} + +// replaceButton swaps the first top-level action row button with the given custom id. +// The bot's own row is always appended at the top level, including on V2 messages. +func replaceButton(components []component.Component, customId string, replacement component.Component) bool { + for _, comp := range components { + row, ok := comp.ComponentData.(component.ActionRow) + if !ok { + continue + } + + for j, btnComp := range row.Components { + btn, ok := btnComp.ComponentData.(component.Button) + if ok && btn.CustomId == customId { + row.Components[j] = replacement + return true + } + } } - // Edit the message with updated components - editData := rest.EditMessageData{ + return false +} + +// ShouldRewriteClassicWelcome reports whether /switchpanel may replace the welcome +// embed. Only an embed can be rewritten in place, so a V2 message on either side is +// left as it is. +func ShouldRewriteClassicWelcome(msg message.Message, newPanel *database.Panel) bool { + return !IsComponentsV2Message(msg) && !newPanel.WelcomeMessageUsesComponentsV2 +} + +func IsComponentsV2Message(msg message.Message) bool { + return msg.Flags&int(message.FlagComponentsV2) != 0 +} + +// welcomeMessageEditData rebuilds an edit from an existing welcome message. Discord +// rejects an edit that drops IS_COMPONENTS_V2 and requires embeds to be empty on V2 +// messages. V2 text is mention-parsed, and an edit without allowed_mentions re-parses +// it with default allowances, which would ping from form answers. +func welcomeMessageEditData(msg message.Message) rest.EditMessageData { + data := rest.EditMessageData{ Content: msg.Content, - Embeds: embeds, + Flags: uint(msg.Flags), Components: msg.Components, } - _, err = worker.EditMessage(*ticket.ChannelId, *ticket.WelcomeMessageId, editData) - return err + if IsComponentsV2Message(msg) { + data.Embeds = []*embed.Embed{} + data.AllowedMentions = &message.AllowedMention{} + return data + } + + data.Embeds = make([]*embed.Embed, len(msg.Embeds)) + for i := range msg.Embeds { + data.Embeds[i] = &msg.Embeds[i] + } + + return data } diff --git a/bot/logic/welcomemessage.go b/bot/logic/welcomemessage.go index 6d284cf1..3298b2fe 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -12,7 +12,6 @@ import ( "sync" "time" - "github.com/TicketsBot-cloud/common/featureflags" "github.com/TicketsBot-cloud/common/model" "github.com/TicketsBot-cloud/common/premium" "github.com/TicketsBot-cloud/common/sentry" @@ -47,8 +46,13 @@ func SendWelcomeMessage( // Only custom integration placeholders for now - prevent making duplicate requests additionalPlaceholders map[string]string, ) (uint64, error) { - if panel != nil && panel.WelcomeMessageUsesComponentsV2 && utils.FeatureFlags.IsEnabled(ctx, "202608_COMPONENTS_V2_BUILDER", featureflags.ForGuild(ticket.GuildId)) { - return sendWelcomeMessageV2(ctx, cmd, ticket, panel, formData, additionalPlaceholders) + if tree := loadWelcomeV2Tree(ctx, cmd, ticket, panel); tree != nil { + msgId, err := sendWelcomeMessageV2(ctx, cmd, ticket, panel, tree, formData, additionalPlaceholders) + if err == nil { + return msgId, nil + } + + logging.WarnWithContext(err, cmd.ToErrorContext(), zap.Uint64("guild_id", ticket.GuildId), zap.Int("panel_id", panel.PanelId)) } // Build embeds @@ -133,10 +137,57 @@ func SendWelcomeMessage( return msg.Id, nil } -const ( - componentsV2MaxTopLevel = 10 - componentsV2MaxTotal = 30 -) +// welcomeV2Tree returns the stored Components V2 tree when the V2 welcome should be +// sent, or nil when the classic welcome applies. Nothing is cleared on the panel, so +// V2 resumes as soon as paid premium returns. +func welcomeV2Tree(panel *database.Panel, paidPremium bool) ([]component.Component, error) { + if panel == nil || !panel.WelcomeMessageUsesComponentsV2 || !paidPremium { + return nil, nil + } + + // Older rows store NULL where newer ones store an empty array. + if panel.WelcomeMessageComponents == nil { + return nil, nil + } + + var tree []component.Component + if err := json.Unmarshal([]byte(*panel.WelcomeMessageComponents), &tree); err != nil { + return nil, fmt.Errorf("unmarshal welcome message components: %w", err) + } + + if len(tree) == 0 { + return nil, nil + } + + return tree, nil +} + +// hasPaidPremium mirrors FooterPolicyForContext: voting premium does not count. +func hasPaidPremium(tier premium.PremiumTier, source model.EntitlementSource) bool { + return tier != premium.None && source != model.EntitlementSourceVoting +} + +// loadWelcomeV2Tree resolves the premium source only for panels that opted into V2, so +// classic panels never pay for the extra lookup. +func loadWelcomeV2Tree(ctx context.Context, cmd registry.CommandContext, ticket database.Ticket, panel *database.Panel) []component.Component { + if panel == nil || !panel.WelcomeMessageUsesComponentsV2 || cmd.PremiumTier() == premium.None { + return nil + } + + tier, source, err := utils.PremiumClient.GetTierByGuildIdWithSource(ctx, cmd.GuildId(), cmd.Worker().Token, cmd.Worker().RateLimiter) + if err != nil { + logging.WarnWithContext(err, cmd.ToErrorContext(), zap.Uint64("guild_id", ticket.GuildId), zap.Int("panel_id", panel.PanelId)) + return nil + } + + tree, err := welcomeV2Tree(panel, hasPaidPremium(tier, source)) + if err != nil { + logging.WarnWithContext(err, cmd.ToErrorContext(), zap.Uint64("guild_id", ticket.GuildId), zap.Int("panel_id", panel.PanelId)) + return nil + } + + return tree +} // sendWelcomeMessageV2 sends a welcome message built from a panel's Components V2 tree, // rather than the classic embed-based welcome message. @@ -145,6 +196,7 @@ func sendWelcomeMessageV2( cmd registry.CommandContext, ticket database.Ticket, panel *database.Panel, + tree []component.Component, formData map[database.FormInput]string, // Only custom integration placeholders for now - prevent making duplicate requests additionalPlaceholders map[string]string, @@ -153,14 +205,19 @@ func sendWelcomeMessageV2( return 0, fmt.Errorf("channel is nil") } - if panel.WelcomeMessageComponents == nil { - return 0, fmt.Errorf("panel %d has components v2 welcome message enabled but no components stored", panel.PanelId) - } - - var tree []component.Component - if err := json.Unmarshal([]byte(*panel.WelcomeMessageComponents), &tree); err != nil { - return 0, fmt.Errorf("unmarshal welcome message components: %w", err) - } + // Substitution happens before the form answers are appended, so ticket-opener + // input is never treated as a placeholder. + tree = transformComponentText(tree, componentTextTransform{ + Text: func(s string) string { + return DoPlaceholderSubstitutions(ctx, s, cmd.Worker(), ticket, additionalPlaceholders) + }, + Label: func(s string) string { + return DoPlainTextPlaceholderSubstitutions(ctx, s, cmd.Worker(), ticket, additionalPlaceholders) + }, + Url: func(s string) string { + return replaceAvatarPlaceholder(cmd.Worker(), ticket, s) + }, + }) var buttons []component.Component if !panel.HideCloseButton { @@ -190,93 +247,102 @@ func sendWelcomeMessageV2( })) } - // Work out how much of Discord's 10 top-level / 30 total component budget is - // left over for form-answer content, after the panel's own tree and the button - // row. This can only be known at send time, since the panel's tree is opaque - // JSON authored in the dashboard. - usedTopLevel := len(tree) - usedTotal := countComponents(tree) - if len(buttons) > 0 { - usedTopLevel++ // the action row itself - usedTotal += 1 + len(buttons) // the action row plus its buttons + finalTree := buildWelcomeV2Components(tree, getFormDataFields(formData), buttons) + + // Form answers are ticket-opener input and V2 text is mention-parsed, unlike embed + // fields. CreateMessageData sends allowed_mentions without omitempty, so the zero + // value goes out as an explicit empty object and suppresses every mention. + data := rest.CreateMessageData{ + Flags: message.SumFlags(message.FlagComponentsV2), + Components: finalTree, } - budget := componentsV2MaxTopLevel - usedTopLevel - if remaining := componentsV2MaxTotal - usedTotal; remaining < budget { - budget = remaining + msg, err := cmd.Worker().CreateMessageComplex(*ticket.ChannelId, data) + if err != nil { + return 0, err } - fields := getFormDataFields(formData) + return msg.Id, nil +} - var formComponents []component.Component - truncated := false - for _, field := range fields { - if budget <= 0 { - truncated = true - break - } +func buildWelcomeV2Components(tree []component.Component, fields []embed.EmbedField, buttons []component.Component) []component.Component { + out := make([]component.Component, 0, len(tree)+len(fields)+1) + out = append(out, tree...) - formComponents = append(formComponents, component.BuildTextDisplay(component.TextDisplay{ + for _, field := range fields { + out = append(out, component.BuildTextDisplay(component.TextDisplay{ Content: fmt.Sprintf("**%s**\n%s", field.Name, utils.EscapeMarkdown(field.Value)), })) - budget-- } - if truncated { - sentry.Log("welcome message v2: form answers truncated, component budget exceeded", map[string]interface{}{ - "guild_id": ticket.GuildId, - "ticket_id": ticket.Id, - "panel_id": panel.PanelId, - }) - } - - finalTree := make([]component.Component, 0, len(tree)+len(formComponents)+1) - finalTree = append(finalTree, tree...) - finalTree = append(finalTree, formComponents...) if len(buttons) > 0 { - finalTree = append(finalTree, component.BuildActionRow(buttons...)) + out = append(out, component.BuildActionRow(buttons...)) } - // formComponents' TextDisplay content is attacker-controlled (the ticket-opener's own - // form answers), and unlike embed fields, Components V2 text content is parsed for - // mentions. AllowedMentions is left as its zero value deliberately: CreateMessageData - // has no `omitempty` on that field, so it is always sent as an explicit empty object - // (`{"replied_user":false}`, no "parse"/"roles"/"users"), which Discord treats as - // suppressing every mention type - the same protection tag.go/closerequest.go apply - // explicitly elsewhere in this codebase for other user-controlled message content. - data := rest.CreateMessageData{ - Flags: message.SumFlags(message.FlagComponentsV2), - Components: finalTree, + return out +} + +// componentTextTransform rewrites the authored strings of a component tree. Url is +// applied to media and link button URLs, matching the classic path, which only +// resolves the avatar placeholder there. +type componentTextTransform struct { + Text func(string) string + Label func(string) string + Url func(string) string +} + +// transformComponentText returns a rewritten copy of the tree and leaves the input untouched. +func transformComponentText(components []component.Component, t componentTextTransform) []component.Component { + if components == nil { + return nil } - msg, err := cmd.Worker().CreateMessageComplex(*ticket.ChannelId, data) - if err != nil { - return 0, err + out := make([]component.Component, len(components)) + for i, c := range components { + out[i] = transformComponentTextOne(c, t) } - return msg.Id, nil + return out } -// countComponents recursively counts a component tree, including components nested -// within action rows, containers and sections, to approximate Discord's 30-component -// total limit. -func countComponents(components []component.Component) int { - count := len(components) - for _, c := range components { - switch data := c.ComponentData.(type) { - case component.ActionRow: - count += countComponents(data.Components) - case component.Container: - count += countComponents(data.Components) - case component.Section: - count += countComponents(data.Components) - if data.Accessory.ComponentData != nil { - count++ - } +func transformComponentTextOne(c component.Component, t componentTextTransform) component.Component { + switch data := c.ComponentData.(type) { + case component.TextDisplay: + data.Content = t.Text(data.Content) + return component.BuildTextDisplay(data) + case component.Section: + data.Components = transformComponentText(data.Components, t) + if data.Accessory.ComponentData != nil { + data.Accessory = transformComponentTextOne(data.Accessory, t) + } + return component.BuildSection(data) + case component.Container: + data.Components = transformComponentText(data.Components, t) + return component.BuildContainer(data) + case component.ActionRow: + data.Components = transformComponentText(data.Components, t) + return component.BuildActionRow(data.Components...) + case component.Button: + data.Label = t.Label(data.Label) + if data.Url != nil { + resolved := t.Url(*data.Url) + data.Url = &resolved + } + return component.BuildButton(data) + case component.Thumbnail: + data.Media.Url = t.Url(data.Media.Url) + return component.BuildThumbnail(data) + case component.MediaGallery: + items := make([]component.MediaGalleryItem, len(data.Items)) + copy(items, data.Items) + for i := range items { + items[i].Media.Url = t.Url(items[i].Media.Url) } + data.Items = items + return component.BuildMediaGallery(data) } - return count + return c } func BuildWelcomeMessageEmbed( diff --git a/bot/logic/welcomemessage_v2_test.go b/bot/logic/welcomemessage_v2_test.go new file mode 100644 index 00000000..94092296 --- /dev/null +++ b/bot/logic/welcomemessage_v2_test.go @@ -0,0 +1,290 @@ +package logic + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/TicketsBot-cloud/common/model" + "github.com/TicketsBot-cloud/common/premium" + "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/stretchr/testify/require" +) + +func strPtr(s string) *string { return &s } + +func TestHasPaidPremium(t *testing.T) { + tests := []struct { + name string + tier premium.PremiumTier + source model.EntitlementSource + want bool + }{ + {"no premium", premium.None, model.EntitlementSourceVoting, false}, + {"no premium with paid source", premium.None, model.EntitlementSourceDiscord, false}, + {"voting premium", premium.Premium, model.EntitlementSourceVoting, false}, + {"paid premium", premium.Premium, model.EntitlementSourceDiscord, true}, + {"paid whitelabel", premium.Whitelabel, model.EntitlementSourcePatreon, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, hasPaidPremium(tt.tier, tt.source)) + }) + } +} + +func TestWelcomeV2Tree(t *testing.T) { + validTree := `[{"type":10,"content":"hello"}]` + + tests := []struct { + name string + panel *database.Panel + paid bool + wantLen int + wantError bool + }{ + {"nil panel", nil, true, 0, false}, + {"panel not using v2", &database.Panel{WelcomeMessageComponents: strPtr(validTree)}, true, 0, false}, + {"paid with tree", &database.Panel{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: strPtr(validTree)}, true, 1, false}, + {"no paid premium", &database.Panel{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: strPtr(validTree)}, false, 0, false}, + {"null tree", &database.Panel{WelcomeMessageUsesComponentsV2: true}, true, 0, false}, + {"empty array tree", &database.Panel{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: strPtr("[]")}, true, 0, false}, + {"json null tree", &database.Panel{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: strPtr("null")}, true, 0, false}, + {"malformed tree", &database.Panel{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: strPtr("{")}, true, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tree, err := welcomeV2Tree(tt.panel, tt.paid) + if tt.wantError { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Len(t, tree, tt.wantLen) + }) + } +} + +func TestTransformComponentText(t *testing.T) { + linkUrl := "https://example.com/%user%" + tree := []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildSection(component.Section{ + Components: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "section %user%"}), + }, + Accessory: component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "%avatar_url%"}}), + }), + component.BuildActionRow( + component.BuildButton(component.Button{Label: "Docs %user%", Style: component.ButtonStyleLink, Url: &linkUrl}), + ), + component.BuildMediaGallery(component.MediaGallery{Items: []component.MediaGalleryItem{{Media: component.UnfurledMediaItem{Url: "%avatar_url%"}}}}), + }, + }), + component.BuildTextDisplay(component.TextDisplay{Content: "top %user%"}), + component.BuildSeparator(component.Separator{}), + } + + got := transformComponentText(tree, componentTextTransform{ + Text: func(s string) string { return strings.ReplaceAll(s, "%user%", "TEXT") }, + Label: func(s string) string { return strings.ReplaceAll(s, "%user%", "LABEL") }, + Url: func(s string) string { return strings.ReplaceAll(s, "%avatar_url%", "AVATAR") }, + }) + + container := got[0].ComponentData.(component.Container) + section := container.Components[0].ComponentData.(component.Section) + require.Equal(t, "section TEXT", section.Components[0].ComponentData.(component.TextDisplay).Content) + require.Equal(t, "AVATAR", section.Accessory.ComponentData.(component.Thumbnail).Media.Url) + + button := container.Components[1].ComponentData.(component.ActionRow).Components[0].ComponentData.(component.Button) + require.Equal(t, "Docs LABEL", button.Label) + require.Equal(t, "https://example.com/%user%", *button.Url, "only the avatar placeholder is resolved in URLs") + + gallery := container.Components[2].ComponentData.(component.MediaGallery) + require.Equal(t, "AVATAR", gallery.Items[0].Media.Url) + + require.Equal(t, "top TEXT", got[1].ComponentData.(component.TextDisplay).Content) + require.Equal(t, component.ComponentSeparator, got[2].Type) + + // The stored tree must not be mutated, as it is shared with the panel row. + require.Equal(t, "top %user%", tree[1].ComponentData.(component.TextDisplay).Content) + require.Equal(t, "Docs %user%", tree[0].ComponentData.(component.Container).Components[1].ComponentData.(component.ActionRow).Components[0].ComponentData.(component.Button).Label) + + require.Nil(t, transformComponentText(nil, componentTextTransform{})) +} + +func TestBuildWelcomeV2ComponentsAppendsEveryAnswer(t *testing.T) { + authored := []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "welcome"})} + buttons := []component.Component{component.BuildButton(component.Button{CustomId: "close"})} + + tests := []struct { + name string + answers int + buttons []component.Component + wantTotal int + }{ + {"no answers", 0, buttons, 2}, + {"no answers or buttons", 0, nil, 1}, + {"under the old budget", 3, buttons, 5}, + {"over the old budget", 45, buttons, 47}, + {"no buttons", 12, nil, 13}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := make([]embed.EmbedField, tt.answers) + for i := range fields { + fields[i] = embed.EmbedField{Name: "Q", Value: "%user% *answer*"} + } + + got := buildWelcomeV2Components(authored, fields, tt.buttons) + require.Len(t, got, tt.wantTotal) + + if tt.answers > 0 { + answer := got[len(authored)].ComponentData.(component.TextDisplay).Content + require.Contains(t, answer, "%user%", "form answers are never substituted") + } + + if len(tt.buttons) > 0 { + require.Equal(t, component.ComponentActionRow, got[len(got)-1].Type) + } + }) + } +} + +func v2ClaimRow() component.Component { + return component.BuildActionRow( + component.BuildButton(component.Button{CustomId: "close"}), + component.BuildButton(component.Button{CustomId: "claim"}), + ) +} + +func TestWelcomeMessageEditData(t *testing.T) { + v2Flags := int(message.FlagComponentsV2) + + tests := []struct { + name string + msg message.Message + wantFlags float64 + wantEmbeds int + wantMentions bool + }{ + { + name: "components v2 keeps flags, empties embeds and blocks mentions", + msg: message.Message{ + Flags: v2Flags, + Embeds: []embed.Embed{{Title: "stale"}}, + Components: []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "hi @everyone"}), v2ClaimRow()}, + }, + wantFlags: float64(v2Flags), + wantEmbeds: 0, + wantMentions: true, + }, + { + name: "classic keeps embeds and leaves mentions alone", + msg: message.Message{ + Flags: 0, + Embeds: []embed.Embed{{Title: "welcome"}}, + Components: []component.Component{v2ClaimRow()}, + }, + wantFlags: 0, + wantEmbeds: 1, + wantMentions: false, + }, + { + name: "classic without embeds sends an empty array", + msg: message.Message{Components: []component.Component{v2ClaimRow()}}, + wantEmbeds: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, err := json.Marshal(welcomeMessageEditData(tt.msg)) + require.NoError(t, err) + + var body map[string]any + require.NoError(t, json.Unmarshal(raw, &body)) + + require.Equal(t, tt.wantFlags, body["flags"]) + require.IsType(t, []any{}, body["embeds"], "embeds must serialise as an array, not null") + require.Len(t, body["embeds"], tt.wantEmbeds) + + mentions, present := body["allowed_mentions"] + require.Equal(t, tt.wantMentions, present) + if present { + require.Equal(t, map[string]any{"replied_user": false}, mentions) + } + }) + } +} + +func TestReplaceButton(t *testing.T) { + unclaim := component.BuildButton(component.Button{CustomId: "unclaim"}) + + tests := []struct { + name string + components []component.Component + fromId string + want bool + }{ + {"classic message", []component.Component{v2ClaimRow()}, "claim", true}, + { + "v2 message with container and text before the row", + []component.Component{ + component.BuildContainer(component.Container{Components: []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "x"})}}), + component.BuildTextDisplay(component.TextDisplay{Content: "answer"}), + v2ClaimRow(), + }, + "claim", + true, + }, + {"button absent", []component.Component{v2ClaimRow()}, "unclaim", false}, + {"no components", nil, "claim", false}, + {"no action row", []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "x"})}, "claim", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, replaceButton(tt.components, tt.fromId, unclaim)) + + if tt.want { + row := tt.components[len(tt.components)-1].ComponentData.(component.ActionRow) + require.Equal(t, "unclaim", row.Components[1].ComponentData.(component.Button).CustomId) + require.Equal(t, "close", row.Components[0].ComponentData.(component.Button).CustomId) + } + }) + } +} + +func TestShouldRewriteClassicWelcome(t *testing.T) { + classicMsg := message.Message{} + v2Msg := message.Message{Flags: int(message.FlagComponentsV2)} + classicPanel := &database.Panel{} + v2Panel := &database.Panel{WelcomeMessageUsesComponentsV2: true} + + tests := []struct { + name string + msg message.Message + panel *database.Panel + want bool + }{ + {"classic message, classic panel", classicMsg, classicPanel, true}, + {"classic message, v2 panel", classicMsg, v2Panel, false}, + {"v2 message, classic panel", v2Msg, classicPanel, false}, + {"v2 message, v2 panel", v2Msg, v2Panel, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, ShouldRewriteClassicWelcome(tt.msg, tt.panel)) + }) + } +} diff --git a/go.mod b/go.mod index 45d8b966..4571c130 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( cloud.google.com/go/profiler v0.4.2 github.com/TicketsBot-cloud/archiverclient v0.0.0-20251015181023-f0b66a074704 github.com/TicketsBot-cloud/common v0.0.0-20260927072029-424264fc4be2 - github.com/TicketsBot-cloud/database v0.0.0-20261002063446-01dca52a987e + github.com/TicketsBot-cloud/database v0.0.0-20261008170428-3b63509d3da8 github.com/TicketsBot-cloud/gdl v0.0.0-20260927072153-8dbf0b284f57 github.com/caarlos0/env/v10 v10.0.0 github.com/elliotchance/orderedmap v1.8.0 diff --git a/go.sum b/go.sum index c4d63865..acf4748c 100644 --- a/go.sum +++ b/go.sum @@ -153,6 +153,7 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= @@ -173,6 +174,7 @@ github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5W github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= From 86375b534f0675c2053b7f47391f46623a73ee48 Mon Sep 17 00:00:00 2001 From: Ben Hall Date: Sun, 18 Oct 2026 20:02:12 +0100 Subject: [PATCH 3/5] fixes based on feedback Signed-off-by: Ben Hall --- .gitignore | 8 +- bot/logic/welcomemessage.go | 45 ++- bot/logic/welcomemessage_limits.go | 233 ++++++++++++ bot/logic/welcomemessage_limits_test.go | 455 ++++++++++++++++++++++++ go.mod | 2 +- 5 files changed, 725 insertions(+), 18 deletions(-) create mode 100644 bot/logic/welcomemessage_limits.go create mode 100644 bot/logic/welcomemessage_limits_test.go diff --git a/.gitignore b/.gitignore index 0d310c2d..94d20521 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ -.idea -*.iml -.env \ No newline at end of file +.idea +*.iml +.env +go.work +go.work.sum diff --git a/bot/logic/welcomemessage.go b/bot/logic/welcomemessage.go index 3298b2fe..0ff7a777 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -11,6 +11,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/TicketsBot-cloud/common/model" "github.com/TicketsBot-cloud/common/premium" @@ -69,15 +70,19 @@ func SendWelcomeMessage( formAnswersEmbed := embed.NewEmbed(). SetColor(welcomeMessageEmbed.Color) - for _, field := range fields { - formAnswersEmbed.AddField(field.Name, utils.EscapeMarkdown(field.Value), field.Inline) - } - if cmd.PremiumTier() == premium.None { formAnswersEmbed.SetFooter(fmt.Sprintf("Powered by %s", config.Conf.Bot.PoweredBy), config.Conf.Bot.IconUrl) } - embeds = append(embeds, formAnswersEmbed) + used := embedTextLength(welcomeMessageEmbed) + embedTextLength(formAnswersEmbed) + fitted := fitClassicFormAnswers(fields, used) + for _, field := range fitted { + formAnswersEmbed.AddField(field.Name, field.Value, field.Inline) + } + + if len(fitted) > 0 { + embeds = append(embeds, formAnswersEmbed) + } } var hideClose, hideCloseWithReason, hideClaim bool @@ -189,8 +194,6 @@ func loadWelcomeV2Tree(ctx context.Context, cmd registry.CommandContext, ticket return tree } -// sendWelcomeMessageV2 sends a welcome message built from a panel's Components V2 tree, -// rather than the classic embed-based welcome message. func sendWelcomeMessageV2( ctx context.Context, cmd registry.CommandContext, @@ -212,7 +215,7 @@ func sendWelcomeMessageV2( return DoPlaceholderSubstitutions(ctx, s, cmd.Worker(), ticket, additionalPlaceholders) }, Label: func(s string) string { - return DoPlainTextPlaceholderSubstitutions(ctx, s, cmd.Worker(), ticket, additionalPlaceholders) + return truncateRunes(DoPlainTextPlaceholderSubstitutions(ctx, s, cmd.Worker(), ticket, additionalPlaceholders), buttonLabelLimit) }, Url: func(s string) string { return replaceAvatarPlaceholder(cmd.Worker(), ticket, s) @@ -265,13 +268,27 @@ func sendWelcomeMessageV2( return msg.Id, nil } +// Authored text takes priority for the text display budget; answers share what is left. func buildWelcomeV2Components(tree []component.Component, fields []embed.EmbedField, buttons []component.Component) []component.Component { - out := make([]component.Component, 0, len(tree)+len(fields)+1) + tree, used := fitTemplateText(tree, v2TextDisplayBudget) + + headers := make([]string, len(fields)) + values := make([]string, len(fields)) + overheads := make([]int, len(fields)) + for i, field := range fields { + headers[i] = v2AnswerHeader(field) + values[i] = field.Value + overheads[i] = utf8.RuneCountInString(headers[i]) + } + + answers := fitAnswerValues(values, overheads, v2TextDisplayBudget-used, 0) + + out := make([]component.Component, 0, len(tree)+len(answers)+1) out = append(out, tree...) - for _, field := range fields { + for i, answer := range answers { out = append(out, component.BuildTextDisplay(component.TextDisplay{ - Content: fmt.Sprintf("**%s**\n%s", field.Name, utils.EscapeMarkdown(field.Value)), + Content: headers[i] + answer, })) } @@ -308,7 +325,7 @@ func transformComponentText(components []component.Component, t componentTextTra func transformComponentTextOne(c component.Component, t componentTextTransform) component.Component { switch data := c.ComponentData.(type) { case component.TextDisplay: - data.Content = t.Text(data.Content) + data.Content = expandOrKeepRaw(data.Content, t.Text(data.Content)) return component.BuildTextDisplay(data) case component.Section: data.Components = transformComponentText(data.Components, t) @@ -323,7 +340,7 @@ func transformComponentTextOne(c component.Component, t componentTextTransform) data.Components = transformComponentText(data.Components, t) return component.BuildActionRow(data.Components...) case component.Button: - data.Label = t.Label(data.Label) + data.Label = expandOrKeepRaw(data.Label, t.Label(data.Label)) if data.Url != nil { resolved := t.Url(*data.Url) data.Url = &resolved @@ -944,7 +961,7 @@ func getFormDataFields(formData map[database.FormInput]string) []embed.EmbedFiel var fields []embed.EmbedField // Can't use len(formData), as form may have changed since modal was opened for _, input := range inputs { answer, ok := formData[input] - if answer == "" { + if strings.TrimSpace(answer) == "" { answer = "N/A" // TODO: What should we use here? } diff --git a/bot/logic/welcomemessage_limits.go b/bot/logic/welcomemessage_limits.go new file mode 100644 index 00000000..e8defa94 --- /dev/null +++ b/bot/logic/welcomemessage_limits.go @@ -0,0 +1,233 @@ +package logic + +import ( + "sort" + "strings" + "unicode/utf8" + + "github.com/TicketsBot-cloud/gdl/objects/channel/embed" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/TicketsBot-cloud/worker/bot/utils" +) + +const ( + v2TextDisplayBudget = 4000 + buttonLabelLimit = 80 + + embedFieldValueLimit = 1024 + embedTotalLimit = 6000 + + truncationEllipsis = "โ€ฆ" +) + +// Trailing backslashes are dropped so the ellipsis is never escaped. +func truncateWithEllipsis(s string, limit int) string { + if limit <= 0 { + return "" + } + + if utf8.RuneCountInString(s) <= limit { + return s + } + + runes := []rune(s) + cut := strings.TrimRight(string(runes[:limit-1]), "\\") + return cut + truncationEllipsis +} + +// truncateEscaped returns EscapeMarkdown(raw) cut to at most limit runes. The raw text +// is cut before escaping, so a cut can never split an escape sequence. +func truncateEscaped(raw string, limit int) string { + if limit <= 0 { + return "" + } + + escaped := utils.EscapeMarkdown(raw) + if utf8.RuneCountInString(escaped) <= limit { + return escaped + } + + runes := []rune(raw) + lo, hi := 0, len(runes) + for lo < hi { + mid := (lo + hi + 1) / 2 + if utf8.RuneCountInString(utils.EscapeMarkdown(string(runes[:mid]))) < limit { + lo = mid + } else { + hi = mid - 1 + } + } + + return utils.EscapeMarkdown(string(runes[:lo])) + truncationEllipsis +} + +// waterFill shares budget between items, letting short items keep their full length +// so one long item cannot starve the rest. +func waterFill(lengths []int, budget int) []int { + out := make([]int, len(lengths)) + if budget <= 0 || len(lengths) == 0 { + return out + } + + order := make([]int, len(lengths)) + for i := range order { + order[i] = i + } + + sort.SliceStable(order, func(a, b int) bool { + return lengths[order[a]] < lengths[order[b]] + }) + + remaining := budget + for n, idx := range order { + share := remaining / (len(order) - n) + out[idx] = min(lengths[idx], share) + remaining -= out[idx] + } + + return out +} + +// Answers are dropped from the end once their overhead and an ellipsis no longer fit. +func fitAnswerValues(values []string, overheads []int, budget, perLimit int) []string { + kept := 0 + used := 0 + for i := range values { + if used+overheads[i]+1 > budget { + break + } + + used += overheads[i] + 1 + kept++ + } + + lengths := make([]int, kept) + overheadTotal := 0 + for i := 0; i < kept; i++ { + lengths[i] = utf8.RuneCountInString(utils.EscapeMarkdown(values[i])) + if perLimit > 0 { + lengths[i] = min(lengths[i], perLimit) + } + + overheadTotal += overheads[i] + } + + allowances := waterFill(lengths, budget-overheadTotal) + + out := make([]string, kept) + for i := range out { + out[i] = truncateEscaped(values[i], allowances[i]) + } + + return out +} + +func identityString(s string) string { return s } + +func textDisplayContents(tree []component.Component) []string { + var contents []string + transformComponentText(tree, componentTextTransform{ + Text: func(s string) string { + contents = append(contents, s) + return s + }, + Label: identityString, + Url: identityString, + }) + + return contents +} + +// fitTemplateText trims authored text displays, in document order, so their combined +// length fits within budget. Every later display keeps at least an ellipsis because +// Discord rejects empty text displays and sections need at least one. +func fitTemplateText(tree []component.Component, budget int) ([]component.Component, int) { + contents := textDisplayContents(tree) + + total := 0 + for _, c := range contents { + total += utf8.RuneCountInString(c) + } + + if total <= budget { + return tree, total + } + + fitted := make([]string, len(contents)) + used := 0 + for i, c := range contents { + allowance := max(budget-used-(len(contents)-i-1), 1) + fitted[i] = truncateWithEllipsis(c, allowance) + used += utf8.RuneCountInString(fitted[i]) + } + + next := 0 + tree = transformComponentText(tree, componentTextTransform{ + Text: func(string) string { + s := fitted[next] + next++ + return s + }, + Label: identityString, + Url: identityString, + }) + + return tree, used +} + +func v2AnswerHeader(field embed.EmbedField) string { + return "**" + field.Name + "**\n" +} + +func fitClassicFormAnswers(fields []embed.EmbedField, used int) []embed.EmbedField { + names := make([]string, len(fields)) + values := make([]string, len(fields)) + overheads := make([]int, len(fields)) + for i, field := range fields { + names[i] = truncateRunes(field.Name, embedFieldNameLimit) + values[i] = field.Value + overheads[i] = utf8.RuneCountInString(names[i]) + } + + fitted := fitAnswerValues(values, overheads, embedTotalLimit-used, embedFieldValueLimit) + + out := make([]embed.EmbedField, len(fitted)) + for i, value := range fitted { + out[i] = embed.EmbedField{Name: names[i], Value: value, Inline: fields[i].Inline} + } + + return out +} + +func embedTextLength(e *embed.Embed) int { + if e == nil { + return 0 + } + + n := utf8.RuneCountInString(e.Title) + utf8.RuneCountInString(e.Description) + if e.Footer != nil { + n += utf8.RuneCountInString(e.Footer.Text) + } + + if e.Author != nil { + n += utf8.RuneCountInString(e.Author.Name) + } + + for _, f := range e.Fields { + if f != nil { + n += utf8.RuneCountInString(f.Name) + utf8.RuneCountInString(f.Value) + } + } + + return n +} + +// expandOrKeepRaw mirrors the classic embed path: a placeholder that expands to nothing +// would leave an empty string that Discord rejects, so the raw text is kept instead. +func expandOrKeepRaw(raw, expanded string) string { + if strings.TrimSpace(expanded) == "" { + return raw + } + + return expanded +} diff --git a/bot/logic/welcomemessage_limits_test.go b/bot/logic/welcomemessage_limits_test.go new file mode 100644 index 00000000..42ed067a --- /dev/null +++ b/bot/logic/welcomemessage_limits_test.go @@ -0,0 +1,455 @@ +package logic + +import ( + "strings" + "testing" + "unicode/utf8" + + "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/objects/channel/embed" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/stretchr/testify/require" +) + +func runeLen(s string) int { return utf8.RuneCountInString(s) } + +func TestTruncateWithEllipsis(t *testing.T) { + tests := []struct { + name string + in string + limit int + want string + }{ + {"empty", "", 5, ""}, + {"under limit", "abc", 5, "abc"}, + {"exactly at limit", "abcde", 5, "abcde"}, + {"one over", "abcdef", 5, "abcdโ€ฆ"}, + {"limit one", "abc", 1, "โ€ฆ"}, + {"limit zero", "abc", 0, ""}, + {"negative limit", "abc", -1, ""}, + {"multibyte exactly at limit", "ๆ—ฅๆœฌ่ชž", 3, "ๆ—ฅๆœฌ่ชž"}, + {"multibyte one over", "ๆ—ฅๆœฌ่ชžใงใ™", 4, "ๆ—ฅๆœฌ่ชžโ€ฆ"}, + {"emoji not split", "๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€", 3, "๐Ÿ˜€๐Ÿ˜€โ€ฆ"}, + {"trailing backslash dropped", `ab\*cd`, 4, "abโ€ฆ"}, + {"run of backslashes dropped", `a\\\\bcd`, 5, "aโ€ฆ"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateWithEllipsis(tt.in, tt.limit) + require.Equal(t, tt.want, got) + require.True(t, utf8.ValidString(got)) + if tt.limit >= 0 { + require.LessOrEqual(t, runeLen(got), max(tt.limit, 0)) + } + }) + } +} + +func TestTruncateEscaped(t *testing.T) { + tests := []struct { + name string + raw string + limit int + want string + }{ + {"fits unescaped", "hello", 5, "hello"}, + {"fits escaped exactly", "a*b", 4, `a\*b`}, + {"escape pushes one over", "a*bc", 4, `a\*โ€ฆ`}, + {"never splits an escape", "ab*cd", 4, "abโ€ฆ"}, + {"all markdown", "*****", 5, `\*\*โ€ฆ`}, + {"multibyte", "ๆ—ฅๆœฌ่ชžใƒ†ใ‚ญใ‚นใƒˆ", 4, "ๆ—ฅๆœฌ่ชžโ€ฆ"}, + {"emoji with markdown", "๐Ÿ˜€*๐Ÿ˜€*๐Ÿ˜€", 5, `๐Ÿ˜€\*๐Ÿ˜€โ€ฆ`}, + {"limit one", "abc", 1, "โ€ฆ"}, + {"limit zero", "abc", 0, ""}, + {"empty", "", 3, ""}, + {"cut inside mention", "<@123456789>", 6, "<@123โ€ฆ"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateEscaped(tt.raw, tt.limit) + require.Equal(t, tt.want, got) + require.LessOrEqual(t, runeLen(got), max(tt.limit, 0)) + require.False(t, strings.HasSuffix(strings.TrimSuffix(got, truncationEllipsis), `\`) && strings.HasSuffix(got, truncationEllipsis), + "no dangling escape before the ellipsis") + }) + } +} + +func TestWaterFill(t *testing.T) { + tests := []struct { + name string + lengths []int + budget int + want []int + }{ + {"empty", nil, 10, []int{}}, + {"all fit", []int{2, 3}, 10, []int{2, 3}}, + {"exactly at budget", []int{4, 6}, 10, []int{4, 6}}, + {"one over shares evenly", []int{5, 6}, 10, []int{5, 5}}, + {"short items keep full length", []int{100, 2, 100}, 52, []int{25, 2, 25}}, + {"zero budget", []int{3, 3}, 0, []int{0, 0}}, + {"remainder goes to the longest", []int{10, 10, 10}, 10, []int{3, 3, 4}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := waterFill(tt.lengths, tt.budget) + require.Equal(t, tt.want, got) + + sum := 0 + for _, n := range got { + sum += n + } + require.LessOrEqual(t, sum, max(tt.budget, 0)) + }) + } +} + +func textDisplay(s string) component.Component { + return component.BuildTextDisplay(component.TextDisplay{Content: s}) +} + +func totalTextDisplayLength(tree []component.Component) int { + n := 0 + for _, c := range textDisplayContents(tree) { + n += runeLen(c) + } + return n +} + +func answerFields(values ...string) []embed.EmbedField { + fields := make([]embed.EmbedField, len(values)) + for i, v := range values { + fields[i] = embed.EmbedField{Name: "Q", Value: v} + } + return fields +} + +func TestBuildWelcomeV2ComponentsBudget(t *testing.T) { + header := runeLen(v2AnswerHeader(embed.EmbedField{Name: "Q"})) + + tests := []struct { + name string + template []component.Component + answers []string + wantAnswers int + wantTruncated bool + }{ + { + name: "small template, small answers", + template: []component.Component{textDisplay("welcome")}, + answers: []string{"one", "two"}, + wantAnswers: 2, + }, + { + name: "template plus answers exactly at budget", + template: []component.Component{textDisplay(strings.Repeat("a", v2TextDisplayBudget-header-10))}, + answers: []string{strings.Repeat("b", 10)}, + wantAnswers: 1, + }, + { + name: "template plus answers one over budget", + template: []component.Component{textDisplay(strings.Repeat("a", v2TextDisplayBudget-header-10))}, + answers: []string{strings.Repeat("b", 11)}, + wantAnswers: 1, + wantTruncated: true, + }, + { + name: "long welcome and four max length answers", + template: []component.Component{textDisplay(strings.Repeat("w", 3000))}, + answers: []string{strings.Repeat("x", 4000), strings.Repeat("y", 4000), "short", strings.Repeat("z", 4000)}, + wantAnswers: 4, + wantTruncated: true, + }, + { + name: "answers full of markdown", + template: []component.Component{textDisplay(strings.Repeat("w", 3500))}, + answers: []string{strings.Repeat("*_", 2000)}, + wantAnswers: 1, + wantTruncated: true, + }, + { + name: "multibyte answers", + template: []component.Component{textDisplay(strings.Repeat("ๆ—ฅ", 3900))}, + answers: []string{strings.Repeat("๐Ÿ˜€", 500)}, + wantAnswers: 1, + wantTruncated: true, + }, + { + name: "template alone uses the whole budget drops answers", + template: []component.Component{textDisplay(strings.Repeat("a", v2TextDisplayBudget))}, + answers: []string{"answer"}, + wantAnswers: 0, + }, + { + name: "template alone over budget is trimmed", + template: []component.Component{ + component.BuildContainer(component.Container{Components: []component.Component{ + textDisplay(strings.Repeat("a", 3000)), + component.BuildSection(component.Section{ + Components: []component.Component{textDisplay(strings.Repeat("b", 3000))}, + Accessory: component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "https://example.com/a.png"}}), + }), + }}), + textDisplay("tail"), + }, + answers: []string{"answer"}, + wantAnswers: 0, + }, + { + name: "no answers", + template: []component.Component{textDisplay("welcome")}, + answers: nil, + wantAnswers: 0, + }, + { + name: "N/A answer from empty input", + template: []component.Component{textDisplay("welcome")}, + answers: []string{"N/A"}, + wantAnswers: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildWelcomeV2Components(tt.template, answerFields(tt.answers...), nil) + + require.LessOrEqual(t, totalTextDisplayLength(got), v2TextDisplayBudget) + for _, c := range textDisplayContents(got) { + require.NotEmpty(t, strings.TrimSpace(c), "Discord rejects empty text displays") + require.True(t, utf8.ValidString(c)) + } + + appended := got[len(tt.template):] + require.Len(t, appended, tt.wantAnswers) + + truncated := false + for _, c := range appended { + content := c.ComponentData.(component.TextDisplay).Content + require.True(t, strings.HasPrefix(content, "**Q**\n")) + if strings.HasSuffix(content, truncationEllipsis) { + truncated = true + require.False(t, strings.HasSuffix(strings.TrimSuffix(content, truncationEllipsis), `\`)) + } + } + require.Equal(t, tt.wantTruncated, truncated) + }) + } +} + +func TestBuildWelcomeV2ComponentsKeepsShortAnswersWhole(t *testing.T) { + template := []component.Component{textDisplay(strings.Repeat("w", 3000))} + got := buildWelcomeV2Components(template, answerFields(strings.Repeat("x", 4000), "keep me", strings.Repeat("y", 4000)), nil) + + require.Equal(t, "**Q**\nkeep me", got[2].ComponentData.(component.TextDisplay).Content) +} + +func TestFitTemplateText(t *testing.T) { + tests := []struct { + name string + lengths []int + budget int + wantTotal int + wantSame bool + }{ + {"under budget", []int{10, 10}, 30, 20, true}, + {"exactly at budget", []int{15, 15}, 30, 30, true}, + {"one over", []int{15, 16}, 30, 30, false}, + {"first display alone over", []int{50, 10, 10}, 30, 30, false}, + {"single display over", []int{4001}, 4000, 4000, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tree := make([]component.Component, len(tt.lengths)) + for i, n := range tt.lengths { + tree[i] = textDisplay(strings.Repeat("a", n)) + } + + got, used := fitTemplateText(tree, tt.budget) + require.Equal(t, tt.wantTotal, used) + require.Equal(t, tt.wantTotal, totalTextDisplayLength(got)) + for _, c := range textDisplayContents(got) { + require.NotEmpty(t, c) + } + + if tt.wantSame { + require.Equal(t, tree, got) + } + }) + } +} + +func TestTransformComponentTextKeepsRawWhenBlank(t *testing.T) { + tree := []component.Component{ + textDisplay("%nickname%"), + textDisplay("hi %nickname%"), + component.BuildActionRow( + component.BuildButton(component.Button{Label: "%nickname%", CustomId: "a"}), + component.BuildButton(component.Button{Label: "", CustomId: "emoji-only"}), + ), + } + + blank := func(s string) string { return strings.ReplaceAll(s, "%nickname%", " ") } + got := transformComponentText(tree, componentTextTransform{Text: blank, Label: blank, Url: identityString}) + + require.Equal(t, "%nickname%", got[0].ComponentData.(component.TextDisplay).Content) + require.Equal(t, "hi ", got[1].ComponentData.(component.TextDisplay).Content) + + row := got[2].ComponentData.(component.ActionRow) + require.Equal(t, "%nickname%", row.Components[0].ComponentData.(component.Button).Label) + require.Equal(t, "", row.Components[1].ComponentData.(component.Button).Label) +} + +func TestExpandOrKeepRaw(t *testing.T) { + tests := []struct { + name string + raw string + expanded string + want string + }{ + {"expanded used", "%nickname%", "Ben", "Ben"}, + {"empty expansion keeps raw", "%nickname%", "", "%nickname%"}, + {"whitespace expansion keeps raw", "%nickname%", " \n\t", "%nickname%"}, + {"both empty", "", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, expandOrKeepRaw(tt.raw, tt.expanded)) + }) + } +} + +func TestFitClassicFormAnswers(t *testing.T) { + long := strings.Repeat("a", 4000) + + tests := []struct { + name string + fields []embed.EmbedField + used int + wantCount int + check func(t *testing.T, got []embed.EmbedField) + }{ + { + name: "short answers escaped and untouched", + fields: []embed.EmbedField{{Name: "Q", Value: "*hi*"}}, + wantCount: 1, + check: func(t *testing.T, got []embed.EmbedField) { + require.Equal(t, `\*hi\*`, got[0].Value) + }, + }, + { + name: "exactly at field limit", + fields: []embed.EmbedField{{Name: "Q", Value: strings.Repeat("a", embedFieldValueLimit)}}, + wantCount: 1, + check: func(t *testing.T, got []embed.EmbedField) { + require.Equal(t, strings.Repeat("a", embedFieldValueLimit), got[0].Value) + }, + }, + { + name: "one over field limit", + fields: []embed.EmbedField{{Name: "Q", Value: strings.Repeat("a", embedFieldValueLimit+1)}}, + wantCount: 1, + check: func(t *testing.T, got []embed.EmbedField) { + require.Equal(t, embedFieldValueLimit, runeLen(got[0].Value)) + require.True(t, strings.HasSuffix(got[0].Value, truncationEllipsis)) + }, + }, + { + name: "escaping pushes over field limit", + fields: []embed.EmbedField{{Name: "Q", Value: strings.Repeat("*", 600)}}, + wantCount: 1, + check: func(t *testing.T, got []embed.EmbedField) { + require.LessOrEqual(t, runeLen(got[0].Value), embedFieldValueLimit) + require.False(t, strings.HasSuffix(strings.TrimSuffix(got[0].Value, truncationEllipsis), `\`)) + }, + }, + { + name: "multibyte answer", + fields: []embed.EmbedField{{Name: "Q", Value: strings.Repeat("๐Ÿ˜€", 2000)}}, + wantCount: 1, + check: func(t *testing.T, got []embed.EmbedField) { + require.Equal(t, embedFieldValueLimit, runeLen(got[0].Value)) + require.True(t, utf8.ValidString(got[0].Value)) + }, + }, + { + name: "long question label truncated", + fields: []embed.EmbedField{{Name: strings.Repeat("q", 300), Value: "a"}}, + wantCount: 1, + check: func(t *testing.T, got []embed.EmbedField) { + require.Equal(t, embedFieldNameLimit, runeLen(got[0].Name)) + }, + }, + { + name: "five max answers with a full welcome stay under the message total", + fields: []embed.EmbedField{{Name: "Q1", Value: long}, {Name: "Q2", Value: long}, {Name: "Q3", Value: long}, {Name: "Q4", Value: long}, {Name: "Q5", Value: long}}, + used: 4096 + 256, + wantCount: 5, + }, + { + name: "no room left drops answers", + fields: []embed.EmbedField{{Name: "Q", Value: "a"}}, + used: embedTotalLimit, + wantCount: 0, + }, + { + name: "no answers", + fields: nil, + wantCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fitClassicFormAnswers(tt.fields, tt.used) + require.Len(t, got, tt.wantCount) + + total := tt.used + for _, f := range got { + require.LessOrEqual(t, runeLen(f.Value), embedFieldValueLimit) + require.LessOrEqual(t, runeLen(f.Name), embedFieldNameLimit) + require.NotEmpty(t, f.Value) + total += runeLen(f.Name) + runeLen(f.Value) + } + if tt.wantCount > 0 { + require.LessOrEqual(t, total, embedTotalLimit) + } + + if tt.check != nil { + tt.check(t, got) + } + }) + } +} + +func TestEmbedTextLength(t *testing.T) { + e := embed.NewEmbed().SetTitle("ab").SetDescription("ๆ—ฅๆœฌ").SetFooter("foot", "").SetAuthor("me", "", "") + e.AddField("n", "v", false) + + require.Equal(t, 2+2+4+2+2, embedTextLength(e)) + require.Equal(t, 0, embedTextLength(nil)) +} + +func TestGetFormDataFieldsBlankAnswers(t *testing.T) { + tests := []struct { + name string + answer string + want string + }{ + {"empty", "", "N/A"}, + {"whitespace only", " \n\t ", "N/A"}, + {"real answer", "hello", "hello"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := getFormDataFields(map[database.FormInput]string{{Id: 1, Label: "Q"}: tt.answer}) + require.Len(t, fields, 1) + require.Equal(t, tt.want, fields[0].Value) + }) + } +} diff --git a/go.mod b/go.mod index 4571c130..33689937 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/TicketsBot-cloud/worker go 1.26.0 -replace github.com/TicketsBot-cloud/database => ../database +// replace github.com/TicketsBot-cloud/database => ../database // replace github.com/TicketsBot-cloud/common => ../common From da3f8f3aafe438ce8bcc7482d33109e3aee6a3e8 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:12:48 +0100 Subject: [PATCH 4/5] Update go.sum --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index 21a56b76..72dd11ea 100644 --- a/go.sum +++ b/go.sum @@ -153,7 +153,6 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= @@ -174,7 +173,6 @@ github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5W github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= From fa09db93f31c9d3c9d8d5f8f1106302e07641705 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:58:59 +0100 Subject: [PATCH 5/5] Improve V2 welcome message handling Preserve welcome buttons across classic and V2 messages, balance template and answer text within Discord limits, resolve missing avatar URLs safely, and validate premium-aware panel switching. --- bot/command/impl/tickets/switchpanel.go | 2 +- bot/logic/claim.go | 16 ++- bot/logic/welcomemessage.go | 132 ++++++++++-------------- bot/logic/welcomemessage_limits.go | 24 ++--- bot/logic/welcomemessage_limits_test.go | 111 ++++++++++++++++---- bot/logic/welcomemessage_v2_test.go | 117 +++++++++++++++++++-- go.mod | 6 +- 7 files changed, 274 insertions(+), 134 deletions(-) diff --git a/bot/command/impl/tickets/switchpanel.go b/bot/command/impl/tickets/switchpanel.go index 1d08215b..5aa3a257 100644 --- a/bot/command/impl/tickets/switchpanel.go +++ b/bot/command/impl/tickets/switchpanel.go @@ -180,7 +180,7 @@ func (SwitchPanelCommand) Execute(ctx *cmdcontext.SlashCommandContext, panelId i msg, err := ctx.Worker().GetChannelMessage(*ticket.ChannelId, *ticket.WelcomeMessageId) // Error is likely to be due to message being deleted, we want to continue further even if it is - if err == nil && logic.ShouldRewriteClassicWelcome(msg, &newPanel) { + if err == nil && logic.ShouldRewriteClassicWelcome(ctx.Context, ctx, ticket, msg, &newPanel) { var subject string embeds := utils.PtrElems(msg.Embeds) // TODO: Fix types diff --git a/bot/logic/claim.go b/bot/logic/claim.go index 320fc02d..33795def 100644 --- a/bot/logic/claim.go +++ b/bot/logic/claim.go @@ -720,7 +720,6 @@ func UpdateWelcomeMessageClaimButton(ctx context.Context, worker *worker.Context return err } -// replaceButton swaps the first top-level action row button with the given custom id. // The bot's own row is always appended at the top level, including on V2 messages. func replaceButton(components []component.Component, customId string, replacement component.Component) bool { for _, comp := range components { @@ -741,21 +740,18 @@ func replaceButton(components []component.Component, customId string, replacemen return false } -// ShouldRewriteClassicWelcome reports whether /switchpanel may replace the welcome -// embed. Only an embed can be rewritten in place, so a V2 message on either side is -// left as it is. -func ShouldRewriteClassicWelcome(msg message.Message, newPanel *database.Panel) bool { - return !IsComponentsV2Message(msg) && !newPanel.WelcomeMessageUsesComponentsV2 +// Only an embed can be rewritten in place, so a V2 message on either side is left as it is. +func ShouldRewriteClassicWelcome(ctx context.Context, cmd registry.CommandContext, ticket database.Ticket, msg message.Message, newPanel *database.Panel) bool { + return !IsComponentsV2Message(msg) && loadWelcomeV2Tree(ctx, cmd, ticket, newPanel) == nil } func IsComponentsV2Message(msg message.Message) bool { return msg.Flags&int(message.FlagComponentsV2) != 0 } -// welcomeMessageEditData rebuilds an edit from an existing welcome message. Discord -// rejects an edit that drops IS_COMPONENTS_V2 and requires embeds to be empty on V2 -// messages. V2 text is mention-parsed, and an edit without allowed_mentions re-parses -// it with default allowances, which would ping from form answers. +// Discord rejects an edit that drops IS_COMPONENTS_V2 and requires embeds to be empty on V2 +// messages. V2 text is mention-parsed, and an edit without allowed_mentions re-parses it with +// default allowances, which would ping from form answers. func welcomeMessageEditData(msg message.Message) rest.EditMessageData { data := rest.EditMessageData{ Content: msg.Content, diff --git a/bot/logic/welcomemessage.go b/bot/logic/welcomemessage.go index c0894497..4285d3eb 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -85,6 +85,32 @@ func SendWelcomeMessage( } } + buttons := welcomeButtons(cmd, panel, ticket.IsThread) + + data := rest.CreateMessageData{ + Embeds: embeds, + } + + if len(buttons) > 0 { + data.Components = []component.Component{ + component.BuildActionRow(buttons...), + } + } + + // Should never happen + if ticket.ChannelId == nil { + return 0, fmt.Errorf("channel is nil") + } + + msg, err := cmd.Worker().CreateMessageComplex(*ticket.ChannelId, data) + if err != nil { + return 0, err + } + + return msg.Id, nil +} + +func welcomeButtons(cmd registry.CommandContext, panel *database.Panel, isThread bool) []component.Component { var hideClose, hideCloseWithReason, hideClaim bool if panel != nil { hideClose = panel.HideCloseButton @@ -110,7 +136,7 @@ func SendWelcomeMessage( })) } - if !hideClaim && !ticket.IsThread { + if !hideClaim && !isThread { buttons = append(buttons, component.BuildButton(component.Button{ Label: cmd.GetMessage(i18n.TitleClaim), CustomId: "claim", @@ -119,38 +145,15 @@ func SendWelcomeMessage( })) } - data := rest.CreateMessageData{ - Embeds: embeds, - } - - if len(buttons) > 0 { - data.Components = []component.Component{ - component.BuildActionRow(buttons...), - } - } - - // Should never happen - if ticket.ChannelId == nil { - return 0, fmt.Errorf("channel is nil") - } - - msg, err := cmd.Worker().CreateMessageComplex(*ticket.ChannelId, data) - if err != nil { - return 0, err - } - - return msg.Id, nil + return buttons } -// welcomeV2Tree returns the stored Components V2 tree when the V2 welcome should be -// sent, or nil when the classic welcome applies. Nothing is cleared on the panel, so -// V2 resumes as soon as paid premium returns. +// Nothing is cleared on the panel, so V2 resumes as soon as paid premium returns. func welcomeV2Tree(panel *database.Panel, paidPremium bool) ([]component.Component, error) { if panel == nil || !panel.WelcomeMessageUsesComponentsV2 || !paidPremium { return nil, nil } - // Older rows store NULL where newer ones store an empty array. if panel.WelcomeMessageComponents == nil { return nil, nil } @@ -167,13 +170,11 @@ func welcomeV2Tree(panel *database.Panel, paidPremium bool) ([]component.Compone return tree, nil } -// hasPaidPremium mirrors FooterPolicyForContext: voting premium does not count. func hasPaidPremium(tier premium.PremiumTier, source model.EntitlementSource) bool { return tier != premium.None && source != model.EntitlementSourceVoting } -// loadWelcomeV2Tree resolves the premium source only for panels that opted into V2, so -// classic panels never pay for the extra lookup. +// Classic panels never pay for the premium lookup. func loadWelcomeV2Tree(ctx context.Context, cmd registry.CommandContext, ticket database.Ticket, panel *database.Panel) []component.Component { if panel == nil || !panel.WelcomeMessageUsesComponentsV2 || cmd.PremiumTier() == premium.None { return nil @@ -218,43 +219,15 @@ func sendWelcomeMessageV2( return truncateRunes(DoPlainTextPlaceholderSubstitutions(ctx, s, cmd.Worker(), ticket, additionalPlaceholders), buttonLabelLimit) }, Url: func(s string) string { - return replaceAvatarPlaceholder(cmd.Worker(), ticket, s) + return withDefaultAvatar(s, replaceAvatarPlaceholder(cmd.Worker(), ticket, s), ticket.UserId) }, }) - var buttons []component.Component - if !panel.HideCloseButton { - buttons = append(buttons, component.BuildButton(component.Button{ - Label: cmd.GetMessage(i18n.TitleClose), - CustomId: "close", - Style: component.ButtonStyleDanger, - Emoji: &emoji.Emoji{Name: "๐Ÿ”’"}, - })) - } - - if !panel.HideCloseWithReasonButton { - buttons = append(buttons, component.BuildButton(component.Button{ - Label: cmd.GetMessage(i18n.TitleCloseWithReason), - CustomId: "close_with_reason", - Style: component.ButtonStyleDanger, - Emoji: &emoji.Emoji{Name: "๐Ÿ”’"}, - })) - } - - if !panel.HideClaimButton && !ticket.IsThread { - buttons = append(buttons, component.BuildButton(component.Button{ - Label: cmd.GetMessage(i18n.TitleClaim), - CustomId: "claim", - Style: component.ButtonStyleSuccess, - Emoji: &emoji.Emoji{Name: "๐Ÿ™‹โ€โ™‚๏ธ"}, - })) - } - - finalTree := buildWelcomeV2Components(tree, getFormDataFields(formData), buttons) + finalTree := buildWelcomeV2Components(tree, getFormDataFields(formData), welcomeButtons(cmd, panel, ticket.IsThread)) // Form answers are ticket-opener input and V2 text is mention-parsed, unlike embed - // fields. CreateMessageData sends allowed_mentions without omitempty, so the zero - // value goes out as an explicit empty object and suppresses every mention. + // fields. CreateMessageData always sends allowed_mentions, and its zero value, + // {"replied_user":false}, has no parse list, so every mention is suppressed. data := rest.CreateMessageData{ Flags: message.SumFlags(message.FlagComponentsV2), Components: finalTree, @@ -268,27 +241,28 @@ func sendWelcomeMessageV2( return msg.Id, nil } -// Authored text takes priority for the text display budget; answers share what is left. +// Answers are guaranteed some room because staff need them and nothing else shows them. func buildWelcomeV2Components(tree []component.Component, fields []embed.EmbedField, buttons []component.Component) []component.Component { - tree, used := fitTemplateText(tree, v2TextDisplayBudget) - headers := make([]string, len(fields)) - values := make([]string, len(fields)) - overheads := make([]int, len(fields)) + needs := make([]int, len(fields)) + reserve := 0 for i, field := range fields { headers[i] = v2AnswerHeader(field) - values[i] = field.Value - overheads[i] = utf8.RuneCountInString(headers[i]) + needs[i] = utf8.RuneCountInString(headers[i]) + utf8.RuneCountInString(utils.EscapeMarkdown(field.Value)) + reserve += min(needs[i], v2AnswerFloor) } - answers := fitAnswerValues(values, overheads, v2TextDisplayBudget-used, 0) + tree, used := fitTemplateText(tree, v2TextDisplayBudget-reserve) + // Shared over whole answers rather than values, so labels of different lengths can't push one under the floor. + allowances := waterFill(needs, v2TextDisplayBudget-used) - out := make([]component.Component, 0, len(tree)+len(answers)+1) + out := make([]component.Component, 0, len(tree)+len(fields)+1) out = append(out, tree...) - for i, answer := range answers { + for i, field := range fields { + value := truncateEscaped(field.Value, allowances[i]-utf8.RuneCountInString(headers[i])) out = append(out, component.BuildTextDisplay(component.TextDisplay{ - Content: headers[i] + answer, + Content: headers[i] + value, })) } @@ -299,16 +273,13 @@ func buildWelcomeV2Components(tree []component.Component, fields []embed.EmbedFi return out } -// componentTextTransform rewrites the authored strings of a component tree. Url is -// applied to media and link button URLs, matching the classic path, which only -// resolves the avatar placeholder there. +// Url is separate because the classic path only resolves the avatar placeholder in URLs. type componentTextTransform struct { Text func(string) string Label func(string) string Url func(string) string } -// transformComponentText returns a rewritten copy of the tree and leaves the input untouched. func transformComponentText(components []component.Component, t componentTextTransform) []component.Component { if components == nil { return nil @@ -1131,3 +1102,12 @@ func replaceAvatarPlaceholder(worker *worker.Context, ticket database.Ticket, ur return user.AvatarUrl(256) } + +// An opener without a custom avatar resolves to "", which Discord rejects as a media URL. +func withDefaultAvatar(raw, resolved string, userId uint64) string { + if raw != AvatarUrlPlaceholder || resolved != "" { + return resolved + } + + return fmt.Sprintf("https://cdn.discordapp.com/embed/avatars/%d.png", (userId>>22)%6) +} diff --git a/bot/logic/welcomemessage_limits.go b/bot/logic/welcomemessage_limits.go index e8defa94..ca96c632 100644 --- a/bot/logic/welcomemessage_limits.go +++ b/bot/logic/welcomemessage_limits.go @@ -12,6 +12,7 @@ import ( const ( v2TextDisplayBudget = 4000 + v2AnswerFloor = 200 buttonLabelLimit = 80 embedFieldValueLimit = 1024 @@ -35,8 +36,7 @@ func truncateWithEllipsis(s string, limit int) string { return cut + truncationEllipsis } -// truncateEscaped returns EscapeMarkdown(raw) cut to at most limit runes. The raw text -// is cut before escaping, so a cut can never split an escape sequence. +// The raw text is cut before escaping, so a cut can never split an escape sequence. func truncateEscaped(raw string, limit int) string { if limit <= 0 { return "" @@ -61,8 +61,7 @@ func truncateEscaped(raw string, limit int) string { return utils.EscapeMarkdown(string(runes[:lo])) + truncationEllipsis } -// waterFill shares budget between items, letting short items keep their full length -// so one long item cannot starve the rest. +// Short items keep their full length, so one long item cannot starve the rest. func waterFill(lengths []int, budget int) []int { out := make([]int, len(lengths)) if budget <= 0 || len(lengths) == 0 { @@ -88,7 +87,7 @@ func waterFill(lengths []int, budget int) []int { return out } -// Answers are dropped from the end once their overhead and an ellipsis no longer fit. +// Each kept answer needs room for its header and at least an ellipsis. func fitAnswerValues(values []string, overheads []int, budget, perLimit int) []string { kept := 0 used := 0 @@ -138,26 +137,27 @@ func textDisplayContents(tree []component.Component) []string { return contents } -// fitTemplateText trims authored text displays, in document order, so their combined -// length fits within budget. Every later display keeps at least an ellipsis because -// Discord rejects empty text displays and sections need at least one. +// Every display keeps at least an ellipsis because Discord rejects empty text displays and +// sections need at least one. func fitTemplateText(tree []component.Component, budget int) ([]component.Component, int) { contents := textDisplayContents(tree) + lengths := make([]int, len(contents)) total := 0 - for _, c := range contents { - total += utf8.RuneCountInString(c) + for i, c := range contents { + lengths[i] = utf8.RuneCountInString(c) + total += lengths[i] } if total <= budget { return tree, total } + allowances := waterFill(lengths, max(budget, len(contents))) fitted := make([]string, len(contents)) used := 0 for i, c := range contents { - allowance := max(budget-used-(len(contents)-i-1), 1) - fitted[i] = truncateWithEllipsis(c, allowance) + fitted[i] = truncateWithEllipsis(c, max(allowances[i], 1)) used += utf8.RuneCountInString(fitted[i]) } diff --git a/bot/logic/welcomemessage_limits_test.go b/bot/logic/welcomemessage_limits_test.go index 42ed067a..ab2d3882 100644 --- a/bot/logic/welcomemessage_limits_test.go +++ b/bot/logic/welcomemessage_limits_test.go @@ -1,6 +1,7 @@ package logic import ( + "slices" "strings" "testing" "unicode/utf8" @@ -129,13 +130,15 @@ func answerFields(values ...string) []embed.EmbedField { func TestBuildWelcomeV2ComponentsBudget(t *testing.T) { header := runeLen(v2AnswerHeader(embed.EmbedField{Name: "Q"})) + long := strings.Repeat("x", 4000) tests := []struct { - name string - template []component.Component - answers []string - wantAnswers int - wantTruncated bool + name string + template []component.Component + answers []string + wantAnswers int + wantTruncated bool + wantTemplateCut bool }{ { name: "small template, small answers", @@ -150,11 +153,11 @@ func TestBuildWelcomeV2ComponentsBudget(t *testing.T) { wantAnswers: 1, }, { - name: "template plus answers one over budget", - template: []component.Component{textDisplay(strings.Repeat("a", v2TextDisplayBudget-header-10))}, - answers: []string{strings.Repeat("b", 11)}, - wantAnswers: 1, - wantTruncated: true, + name: "template plus answers one over budget", + template: []component.Component{textDisplay(strings.Repeat("a", v2TextDisplayBudget-header-10))}, + answers: []string{strings.Repeat("b", 11)}, + wantAnswers: 1, + wantTemplateCut: true, }, { name: "long welcome and four max length answers", @@ -163,6 +166,21 @@ func TestBuildWelcomeV2ComponentsBudget(t *testing.T) { wantAnswers: 4, wantTruncated: true, }, + { + name: "welcome near the budget keeps every answer", + template: []component.Component{textDisplay(strings.Repeat("w", 3990))}, + answers: []string{"builderman", strings.Repeat("x", 500)}, + wantAnswers: 2, + wantTruncated: true, + wantTemplateCut: true, + }, + { + name: "short template is never cut", + template: []component.Component{textDisplay("welcome")}, + answers: []string{long, long, long, long, long}, + wantAnswers: 5, + wantTruncated: true, + }, { name: "answers full of markdown", template: []component.Component{textDisplay(strings.Repeat("w", 3500))}, @@ -171,17 +189,19 @@ func TestBuildWelcomeV2ComponentsBudget(t *testing.T) { wantTruncated: true, }, { - name: "multibyte answers", - template: []component.Component{textDisplay(strings.Repeat("ๆ—ฅ", 3900))}, - answers: []string{strings.Repeat("๐Ÿ˜€", 500)}, - wantAnswers: 1, - wantTruncated: true, + name: "multibyte answers", + template: []component.Component{textDisplay(strings.Repeat("ๆ—ฅ", 3900))}, + answers: []string{strings.Repeat("๐Ÿ˜€", 500)}, + wantAnswers: 1, + wantTruncated: true, + wantTemplateCut: true, }, { - name: "template alone uses the whole budget drops answers", - template: []component.Component{textDisplay(strings.Repeat("a", v2TextDisplayBudget))}, - answers: []string{"answer"}, - wantAnswers: 0, + name: "template alone uses the whole budget", + template: []component.Component{textDisplay(strings.Repeat("a", v2TextDisplayBudget))}, + answers: []string{"answer"}, + wantAnswers: 1, + wantTemplateCut: true, }, { name: "template alone over budget is trimmed", @@ -195,8 +215,9 @@ func TestBuildWelcomeV2ComponentsBudget(t *testing.T) { }}), textDisplay("tail"), }, - answers: []string{"answer"}, - wantAnswers: 0, + answers: []string{"answer"}, + wantAnswers: 1, + wantTemplateCut: true, }, { name: "no answers", @@ -222,6 +243,9 @@ func TestBuildWelcomeV2ComponentsBudget(t *testing.T) { require.True(t, utf8.ValidString(c)) } + templateCut := !slices.Equal(textDisplayContents(tt.template), textDisplayContents(got[:len(tt.template)])) + require.Equal(t, tt.wantTemplateCut, templateCut) + appended := got[len(tt.template):] require.Len(t, appended, tt.wantAnswers) @@ -246,6 +270,36 @@ func TestBuildWelcomeV2ComponentsKeepsShortAnswersWhole(t *testing.T) { require.Equal(t, "**Q**\nkeep me", got[2].ComponentData.(component.TextDisplay).Content) } +func TestBuildWelcomeV2ComponentsAnswerFloor(t *testing.T) { + template := []component.Component{textDisplay(strings.Repeat("w", 3990))} + got := buildWelcomeV2Components(template, answerFields("builderman", strings.Repeat("x", 500)), nil) + + require.Equal(t, "**Q**\nbuilderman", got[1].ComponentData.(component.TextDisplay).Content) + require.Equal(t, v2AnswerFloor, runeLen(got[2].ComponentData.(component.TextDisplay).Content)) + require.Equal(t, v2TextDisplayBudget, totalTextDisplayLength(got)) +} + +func TestBuildWelcomeV2ComponentsAnswerFloorWithMixedLabels(t *testing.T) { + label := strings.Repeat("L", 45) + long := strings.Repeat("x", 4000) + short := strings.Repeat("s", 190) + fields := []embed.EmbedField{ + {Name: label, Value: long}, + {Name: label, Value: long}, + {Name: "Q", Value: short}, + {Name: label, Value: long}, + {Name: label, Value: long}, + } + + got := buildWelcomeV2Components([]component.Component{textDisplay(strings.Repeat("w", 3990))}, fields, nil) + + require.Equal(t, "**Q**\n"+short, got[3].ComponentData.(component.TextDisplay).Content) + for _, i := range []int{1, 2, 4, 5} { + require.Equal(t, v2AnswerFloor, runeLen(got[i].ComponentData.(component.TextDisplay).Content)) + } + require.Equal(t, v2TextDisplayBudget, totalTextDisplayLength(got)) +} + func TestFitTemplateText(t *testing.T) { tests := []struct { name string @@ -282,6 +336,15 @@ func TestFitTemplateText(t *testing.T) { } } +func TestFitTemplateTextKeepsShortDisplays(t *testing.T) { + closing := "Press Close when you are done." + tree := []component.Component{textDisplay(strings.Repeat("a", 3400)), textDisplay(closing)} + + got, used := fitTemplateText(tree, 3000) + require.Equal(t, 3000, used) + require.Equal(t, closing, textDisplayContents(got)[1]) +} + func TestTransformComponentTextKeepsRawWhenBlank(t *testing.T) { tree := []component.Component{ textDisplay("%nickname%"), @@ -396,6 +459,12 @@ func TestFitClassicFormAnswers(t *testing.T) { used: embedTotalLimit, wantCount: 0, }, + { + name: "room for the name but not a value drops the answer", + fields: []embed.EmbedField{{Name: "Q", Value: "a"}}, + used: embedTotalLimit - 1, + wantCount: 0, + }, { name: "no answers", fields: nil, diff --git a/bot/logic/welcomemessage_v2_test.go b/bot/logic/welcomemessage_v2_test.go index 94092296..59719a47 100644 --- a/bot/logic/welcomemessage_v2_test.go +++ b/bot/logic/welcomemessage_v2_test.go @@ -1,6 +1,7 @@ package logic import ( + "context" "encoding/json" "strings" "testing" @@ -11,6 +12,12 @@ import ( "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/objects/user" + "github.com/TicketsBot-cloud/gdl/rest/ratelimit" + "github.com/TicketsBot-cloud/worker" + "github.com/TicketsBot-cloud/worker/bot/command/registry" + "github.com/TicketsBot-cloud/worker/bot/utils" + "github.com/TicketsBot-cloud/worker/i18n" "github.com/stretchr/testify/require" ) @@ -113,13 +120,35 @@ func TestTransformComponentText(t *testing.T) { require.Equal(t, "top TEXT", got[1].ComponentData.(component.TextDisplay).Content) require.Equal(t, component.ComponentSeparator, got[2].Type) - // The stored tree must not be mutated, as it is shared with the panel row. require.Equal(t, "top %user%", tree[1].ComponentData.(component.TextDisplay).Content) require.Equal(t, "Docs %user%", tree[0].ComponentData.(component.Container).Components[1].ComponentData.(component.ActionRow).Components[0].ComponentData.(component.Button).Label) require.Nil(t, transformComponentText(nil, componentTextTransform{})) } +func TestWithDefaultAvatar(t *testing.T) { + opener := user.User{Id: 80351110224678912} + custom := "https://cdn.discordapp.com/avatars/80351110224678912/abc.webp?size=256" + + tests := []struct { + name string + raw string + resolved string + want string + }{ + {"opener without a custom avatar", AvatarUrlPlaceholder, opener.AvatarUrl(256), "https://cdn.discordapp.com/embed/avatars/5.png"}, + {"opener with a custom avatar", AvatarUrlPlaceholder, custom, custom}, + {"other url", "https://example.com/a.png", "https://example.com/a.png", "https://example.com/a.png"}, + {"empty url", "", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, withDefaultAvatar(tt.raw, tt.resolved, opener.Id)) + }) + } +} + func TestBuildWelcomeV2ComponentsAppendsEveryAnswer(t *testing.T) { authored := []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "welcome"})} buttons := []component.Component{component.BuildButton(component.Button{CustomId: "close"})} @@ -264,27 +293,93 @@ func TestReplaceButton(t *testing.T) { } } +type premiumCommandContext struct { + registry.CommandContext + tier premium.PremiumTier +} + +func (c premiumCommandContext) PremiumTier() premium.PremiumTier { return c.tier } +func (c premiumCommandContext) GuildId() uint64 { return 1 } +func (c premiumCommandContext) Worker() *worker.Context { return &worker.Context{} } + +type countingLookupClient struct { + premium.MockLookupClient + lookups int +} + +func (c *countingLookupClient) GetTierByGuildIdWithSource(ctx context.Context, guildId uint64, token string, rl *ratelimit.Ratelimiter) (premium.PremiumTier, model.EntitlementSource, error) { + c.lookups++ + return c.MockLookupClient.GetTierByGuildIdWithSource(ctx, guildId, token, rl) +} + func TestShouldRewriteClassicWelcome(t *testing.T) { classicMsg := message.Message{} v2Msg := message.Message{Flags: int(message.FlagComponentsV2)} classicPanel := &database.Panel{} - v2Panel := &database.Panel{WelcomeMessageUsesComponentsV2: true} + v2Panel := &database.Panel{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: strPtr(`[{"type":10,"content":"hello"}]`)} tests := []struct { - name string - msg message.Message - panel *database.Panel - want bool + name string + msg message.Message + panel *database.Panel + tier premium.PremiumTier + source model.EntitlementSource + want bool + wantLookup bool + }{ + {"classic message, classic panel", classicMsg, classicPanel, premium.Premium, model.EntitlementSourceDiscord, true, false}, + {"classic message, v2 panel", classicMsg, v2Panel, premium.Premium, model.EntitlementSourceDiscord, false, true}, + {"classic message, v2 panel, voting premium", classicMsg, v2Panel, premium.Premium, model.EntitlementSourceVoting, true, true}, + {"classic message, v2 panel, no premium", classicMsg, v2Panel, premium.None, model.EntitlementSourceDiscord, true, false}, + {"v2 message, classic panel", v2Msg, classicPanel, premium.Premium, model.EntitlementSourceDiscord, false, false}, + {"v2 message, v2 panel", v2Msg, v2Panel, premium.Premium, model.EntitlementSourceDiscord, false, false}, + } + + previous := utils.PremiumClient + t.Cleanup(func() { utils.PremiumClient = previous }) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &countingLookupClient{MockLookupClient: premium.NewMockLookupClient(tt.tier, tt.source)} + utils.PremiumClient = client + + cmd := premiumCommandContext{tier: tt.tier} + require.Equal(t, tt.want, ShouldRewriteClassicWelcome(context.Background(), cmd, database.Ticket{}, tt.msg, tt.panel)) + require.Equal(t, tt.wantLookup, client.lookups > 0) + }) + } +} + +type messageCommandContext struct { + registry.CommandContext +} + +func (messageCommandContext) GetMessage(id i18n.MessageId, _ ...any) string { return string(id) } + +func TestWelcomeButtons(t *testing.T) { + tests := []struct { + name string + panel *database.Panel + isThread bool + want []string }{ - {"classic message, classic panel", classicMsg, classicPanel, true}, - {"classic message, v2 panel", classicMsg, v2Panel, false}, - {"v2 message, classic panel", v2Msg, classicPanel, false}, - {"v2 message, v2 panel", v2Msg, v2Panel, false}, + {"nil panel shows every button", nil, false, []string{"close", "close_with_reason", "claim"}}, + {"channel ticket", &database.Panel{}, false, []string{"close", "close_with_reason", "claim"}}, + {"thread ticket has no claim", &database.Panel{}, true, []string{"close", "close_with_reason"}}, + {"close hidden", &database.Panel{HideCloseButton: true}, false, []string{"close_with_reason", "claim"}}, + {"close with reason hidden", &database.Panel{HideCloseWithReasonButton: true}, false, []string{"close", "claim"}}, + {"claim hidden", &database.Panel{HideClaimButton: true}, false, []string{"close", "close_with_reason"}}, + {"everything hidden", &database.Panel{HideCloseButton: true, HideCloseWithReasonButton: true, HideClaimButton: true}, false, nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.want, ShouldRewriteClassicWelcome(tt.msg, tt.panel)) + var got []string + for _, c := range welcomeButtons(messageCommandContext{}, tt.panel, tt.isThread) { + got = append(got, c.ComponentData.(component.Button).CustomId) + } + + require.Equal(t, tt.want, got) }) } } diff --git a/go.mod b/go.mod index f91b3e11..1775a631 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,11 @@ module github.com/TicketsBot-cloud/worker go 1.26.0 -// replace github.com/TicketsBot-cloud/database => ../database +replace github.com/TicketsBot-cloud/database => ../database -// replace github.com/TicketsBot-cloud/common => ../common +replace github.com/TicketsBot-cloud/common => ../common -// replace github.com/TicketsBot-cloud/gdl => ../gdl +replace github.com/TicketsBot-cloud/gdl => ../gdl // replace github.com/TicketsBot-cloud/archiverclient => ../archiverclient