From fdde9724c403eadadb764c8e20acd97a68f04d1f Mon Sep 17 00:00:00 2001 From: Ben Hall Date: Sun, 6 Sep 2026 17:48:55 +0100 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 From 2b16bd4879c13e874afe8eebd82c7cdf058bde39 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:04:18 +0100 Subject: [PATCH 06/13] Fix V2 welcome message handling Preserve allowed mentions on V2 edits, prevent concurrent placeholder substitutions from interfering, and escape form labels safely within Discord markdown limits. Add coverage for payloads, fallbacks, claim updates, and placeholder behavior. --- bot/command/messageresponse.go | 2 +- bot/command/messageresponse_test.go | 2 + bot/logic/claim.go | 5 - bot/logic/placeholders_test.go | 17 ++ bot/logic/welcomemessage.go | 17 +- bot/logic/welcomemessage_limits.go | 10 +- bot/logic/welcomemessage_limits_test.go | 46 ++++- bot/logic/welcomemessage_v2_test.go | 242 +++++++++++++++++++++++- 8 files changed, 306 insertions(+), 35 deletions(-) create mode 100644 bot/logic/placeholders_test.go diff --git a/bot/command/messageresponse.go b/bot/command/messageresponse.go index 815d532e..93e9c453 100644 --- a/bot/command/messageresponse.go +++ b/bot/command/messageresponse.go @@ -100,9 +100,9 @@ func (r *MessageResponse) IntoEditMessageData() rest.EditMessageData { Components: r.Components, } - // V2 messages cannot carry content, and Discord requires embeds to be an empty array. if r.isComponentsV2() { data.Embeds = []*embed.Embed{} + data.AllowedMentions = &r.AllowedMentions } else { data.Content = r.Content data.Embeds = r.Embeds diff --git a/bot/command/messageresponse_test.go b/bot/command/messageresponse_test.go index 47423866..f861b2d7 100644 --- a/bot/command/messageresponse_test.go +++ b/bot/command/messageresponse_test.go @@ -98,12 +98,14 @@ func TestMessageResponseIntoEditMessageData(t *testing.T) { if tt.wantLegacy { require.Equal(t, "some text", data.Content) require.Len(t, data.Embeds, 1) + require.Nil(t, data.AllowedMentions) 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) + require.NotNil(t, data.AllowedMentions) }) } } diff --git a/bot/logic/claim.go b/bot/logic/claim.go index 33795def..a4ccb90c 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 } -// 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) @@ -740,7 +739,6 @@ func replaceButton(components []component.Component, customId string, replacemen return false } -// 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 } @@ -749,9 +747,6 @@ func IsComponentsV2Message(msg message.Message) bool { return msg.Flags&int(message.FlagComponentsV2) != 0 } -// 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/placeholders_test.go b/bot/logic/placeholders_test.go new file mode 100644 index 00000000..e7760d2c --- /dev/null +++ b/bot/logic/placeholders_test.go @@ -0,0 +1,17 @@ +package logic + +import ( + "context" + "testing" + + "github.com/TicketsBot-cloud/database" + "github.com/stretchr/testify/require" +) + +func TestDoPlaceholderSubstitutions(t *testing.T) { + channelId := uint64(5) + ticket := database.Ticket{Id: 7, UserId: 80351110224678912, ChannelId: &channelId} + + got := DoPlaceholderSubstitutions(context.Background(), "%user_id% %ticket_id% %channel% %user%", nil, ticket, nil) + require.Equal(t, "80351110224678912 7 <#5> <@80351110224678912>", got) +} diff --git a/bot/logic/welcomemessage.go b/bot/logic/welcomemessage.go index 4285d3eb..1f38c77e 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -148,7 +148,6 @@ func welcomeButtons(cmd registry.CommandContext, panel *database.Panel, isThread return buttons } -// 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 @@ -174,7 +173,6 @@ func hasPaidPremium(tier premium.PremiumTier, source model.EntitlementSource) bo return tier != premium.None && source != model.EntitlementSourceVoting } -// 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 @@ -202,15 +200,12 @@ func sendWelcomeMessageV2( 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, ) (uint64, error) { if ticket.ChannelId == nil { return 0, fmt.Errorf("channel is nil") } - // 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) @@ -225,9 +220,6 @@ func sendWelcomeMessageV2( 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 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, @@ -241,7 +233,6 @@ func sendWelcomeMessageV2( return msg.Id, nil } -// 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 { headers := make([]string, len(fields)) needs := make([]int, len(fields)) @@ -253,7 +244,6 @@ func buildWelcomeV2Components(tree []component.Component, fields []embed.EmbedFi } 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(fields)+1) @@ -273,7 +263,6 @@ func buildWelcomeV2Components(tree []component.Component, fields []embed.EmbedFi return out } -// 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 @@ -407,6 +396,9 @@ func doPlaceholderSubstitutions( // Process parameterized placeholders first (e.g., %date_days:30%) message = doParameterizedSubstitutions(ctx, message, worker, ticket, plainTextOnly) + // The goroutines below reassign message, so lookups are picked from a copy. + original := message + var lock sync.Mutex // do DB lookups in parallel @@ -423,7 +415,7 @@ func doPlaceholderSubstitutions( formatted := fmt.Sprintf("%%%s%%", placeholder) - if strings.Contains(message, formatted) { + if strings.Contains(original, formatted) { group.Go(func() error { ctx, cancel := context.WithTimeout(ctx, substitutionTimeout) defer cancel() @@ -1103,7 +1095,6 @@ 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 diff --git a/bot/logic/welcomemessage_limits.go b/bot/logic/welcomemessage_limits.go index ca96c632..bac5ced0 100644 --- a/bot/logic/welcomemessage_limits.go +++ b/bot/logic/welcomemessage_limits.go @@ -36,7 +36,6 @@ func truncateWithEllipsis(s string, limit int) string { return cut + truncationEllipsis } -// 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,7 +60,6 @@ func truncateEscaped(raw string, limit int) string { return utils.EscapeMarkdown(string(runes[:lo])) + truncationEllipsis } -// 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 { @@ -87,7 +85,6 @@ func waterFill(lengths []int, budget int) []int { return out } -// 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 @@ -137,8 +134,6 @@ func textDisplayContents(tree []component.Component) []string { return contents } -// 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) @@ -175,8 +170,9 @@ func fitTemplateText(tree []component.Component, budget int) ([]component.Compon return tree, used } +// EscapeMarkdown leaves backslashes alone, and a trailing one would escape the closing **. func v2AnswerHeader(field embed.EmbedField) string { - return "**" + field.Name + "**\n" + return "**" + utils.EscapeMarkdown(strings.ReplaceAll(field.Name, `\`, `\\`)) + "**\n" } func fitClassicFormAnswers(fields []embed.EmbedField, used int) []embed.EmbedField { @@ -222,8 +218,6 @@ func embedTextLength(e *embed.Embed) int { 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 diff --git a/bot/logic/welcomemessage_limits_test.go b/bot/logic/welcomemessage_limits_test.go index ab2d3882..39e74636 100644 --- a/bot/logic/welcomemessage_limits_test.go +++ b/bot/logic/welcomemessage_limits_test.go @@ -300,6 +300,50 @@ func TestBuildWelcomeV2ComponentsAnswerFloorWithMixedLabels(t *testing.T) { require.Equal(t, v2TextDisplayBudget, totalTextDisplayLength(got)) } +func TestV2AnswerHeaderEscapesLabel(t *testing.T) { + tests := []struct { + name string + label string + want string + }{ + {"plain label", "Q", "**Q**\n"}, + {"bold markers", "Order **ID**", `**Order \*\*ID\*\***` + "\n"}, + {"trailing backslash", `Folder path \`, `**Folder path \\**` + "\n"}, + {"underscores", "__init__ name", `**\_\_init\_\_ name**` + "\n"}, + {"lone asterisk", "Email *", `**Email \***` + "\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, v2AnswerHeader(embed.EmbedField{Name: tt.label})) + }) + } +} + +func TestBuildWelcomeV2ComponentsEscapedLabelBudget(t *testing.T) { + label := strings.Repeat(`*\`, 22) + "*" + header := v2AnswerHeader(embed.EmbedField{Name: label}) + + fields := make([]embed.EmbedField, 5) + for i := range fields { + fields[i] = embed.EmbedField{Name: label, Value: strings.Repeat("x", 4000)} + } + + got := buildWelcomeV2Components([]component.Component{textDisplay(strings.Repeat("w", 3990))}, fields, nil) + + require.Len(t, got, 6) + require.LessOrEqual(t, totalTextDisplayLength(got), v2TextDisplayBudget) + for _, c := range got[1:] { + require.True(t, strings.HasPrefix(c.ComponentData.(component.TextDisplay).Content, header)) + } +} + +func TestBuildWelcomeV2ComponentsKeepsEscapedShortAnswerWhole(t *testing.T) { + got := buildWelcomeV2Components(nil, []embed.EmbedField{{Name: "Order **ID**", Value: "answer"}}, nil) + + require.Equal(t, `**Order \*\*ID\*\***`+"\nanswer", got[0].ComponentData.(component.TextDisplay).Content) +} + func TestFitTemplateText(t *testing.T) { tests := []struct { name string @@ -448,7 +492,7 @@ func TestFitClassicFormAnswers(t *testing.T) { }, }, { - name: "five max answers with a full welcome stay under the message total", + name: "five max answers with a full welcome", 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, diff --git a/bot/logic/welcomemessage_v2_test.go b/bot/logic/welcomemessage_v2_test.go index 59719a47..a7e062dd 100644 --- a/bot/logic/welcomemessage_v2_test.go +++ b/bot/logic/welcomemessage_v2_test.go @@ -3,6 +3,8 @@ package logic import ( "context" "encoding/json" + "io" + "net/http" "strings" "testing" @@ -14,8 +16,11 @@ import ( "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/gdl/rest/request" "github.com/TicketsBot-cloud/worker" "github.com/TicketsBot-cloud/worker/bot/command/registry" + "github.com/TicketsBot-cloud/worker/bot/customisation" + "github.com/TicketsBot-cloud/worker/bot/errorcontext" "github.com/TicketsBot-cloud/worker/bot/utils" "github.com/TicketsBot-cloud/worker/i18n" "github.com/stretchr/testify/require" @@ -162,7 +167,7 @@ func TestBuildWelcomeV2ComponentsAppendsEveryAnswer(t *testing.T) { {"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}, + {"over the old budget", 12, buttons, 14}, {"no buttons", 12, nil, 13}, } @@ -176,11 +181,6 @@ func TestBuildWelcomeV2ComponentsAppendsEveryAnswer(t *testing.T) { 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) } @@ -206,7 +206,7 @@ func TestWelcomeMessageEditData(t *testing.T) { wantMentions bool }{ { - name: "components v2 keeps flags, empties embeds and blocks mentions", + name: "components v2", msg: message.Message{ Flags: v2Flags, Embeds: []embed.Embed{{Title: "stale"}}, @@ -227,6 +227,16 @@ func TestWelcomeMessageEditData(t *testing.T) { wantEmbeds: 1, wantMentions: false, }, + { + name: "classic echoes the flags already set", + msg: message.Message{ + Flags: int(message.FlagSupressEmbeds), + Embeds: []embed.Embed{{Title: "welcome"}}, + Components: []component.Component{v2ClaimRow()}, + }, + wantFlags: float64(message.FlagSupressEmbeds), + wantEmbeds: 1, + }, { name: "classic without embeds sends an empty array", msg: message.Message{Components: []component.Component{v2ClaimRow()}}, @@ -383,3 +393,221 @@ func TestWelcomeButtons(t *testing.T) { }) } } + +type capturedRequest struct { + method string + body map[string]any +} + +type captureTransport struct { + response string + statuses []int + requests []capturedRequest +} + +func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + captured := capturedRequest{method: req.Method} + if req.Body != nil { + raw, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + + if len(raw) > 0 { + if err := json.Unmarshal(raw, &captured.body); err != nil { + return nil, err + } + } + } + + status := http.StatusOK + if len(c.requests) < len(c.statuses) { + status = c.statuses[len(c.requests)] + } + + c.requests = append(c.requests, captured) + + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(c.response)), + Request: req, + }, nil +} + +func (c *captureTransport) bodies(method string) []map[string]any { + var bodies []map[string]any + for _, r := range c.requests { + if r.method == method { + bodies = append(bodies, r.body) + } + } + + return bodies +} + +func captureDiscordRequests(t *testing.T, response string, statuses ...int) *captureTransport { + t.Helper() + + transport := &captureTransport{response: response, statuses: statuses} + previous := request.Client.Transport + request.Client.Transport = transport + t.Cleanup(func() { request.Client.Transport = previous }) + + return transport +} + +func rowCustomIds(t *testing.T, row any) []string { + t.Helper() + + data := row.(map[string]any) + require.Equal(t, float64(component.ComponentActionRow), data["type"]) + + var ids []string + for _, button := range data["components"].([]any) { + ids = append(ids, button.(map[string]any)["custom_id"].(string)) + } + + return ids +} + +type welcomeCommandContext struct { + premiumCommandContext +} + +func (welcomeCommandContext) GetMessage(id i18n.MessageId, _ ...any) string { return string(id) } +func (welcomeCommandContext) GetColour(customisation.Colour) *int { return nil } +func (welcomeCommandContext) ToErrorContext() errorcontext.WorkerErrorContext { + return errorcontext.WorkerErrorContext{} +} + +func welcomeTicket() database.Ticket { + channelId := uint64(7) + return database.Ticket{Id: 5, GuildId: 1, UserId: 80351110224678912, ChannelId: &channelId} +} + +func TestSendWelcomeMessageV2Payload(t *testing.T) { + transport := captureDiscordRequests(t, `{"id":"42"}`) + + tree := []component.Component{textDisplay("Hello %custom% %user%")} + formData := map[database.FormInput]string{{Id: 1, Label: "Q"}: "@everyone <@&123> %custom% %user%"} + cmd := welcomeCommandContext{premiumCommandContext{tier: premium.Premium}} + + id, err := sendWelcomeMessageV2(context.Background(), cmd, welcomeTicket(), &database.Panel{}, tree, formData, map[string]string{"custom": "X"}) + require.NoError(t, err) + require.Equal(t, uint64(42), id) + + posts := transport.bodies(http.MethodPost) + require.Len(t, posts, 1) + + body := posts[0] + require.Equal(t, float64(message.FlagComponentsV2), body["flags"]) + require.Equal(t, map[string]any{"replied_user": false}, body["allowed_mentions"]) + require.NotContains(t, body, "embeds") + + components := body["components"].([]any) + require.Len(t, components, 3) + require.Equal(t, "Hello X <@80351110224678912>", components[0].(map[string]any)["content"]) + require.Equal(t, "**Q**\n@everyone <@&123> %custom% %user%", components[1].(map[string]any)["content"], "form answers are never substituted") + require.Equal(t, []string{"close", "close_with_reason", "claim"}, rowCustomIds(t, components[2])) +} + +func TestSendWelcomeMessageFallsBackToClassic(t *testing.T) { + transport := captureDiscordRequests(t, `{"id":"42"}`, http.StatusBadRequest, http.StatusOK) + + previous := utils.PremiumClient + t.Cleanup(func() { utils.PremiumClient = previous }) + client := premium.NewMockLookupClient(premium.Premium, model.EntitlementSourceDiscord) + utils.PremiumClient = &client + + panel := &database.Panel{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: strPtr(`[{"type":10,"content":"Hello %custom%"}]`)} + formData := map[database.FormInput]string{{Id: 1, Label: "Q"}: "a"} + cmd := welcomeCommandContext{premiumCommandContext{tier: premium.Premium}} + + id, err := SendWelcomeMessage(context.Background(), cmd, welcomeTicket(), "subject", panel, formData, map[string]string{"custom": "X"}) + require.NoError(t, err) + require.Equal(t, uint64(42), id) + + posts := transport.bodies(http.MethodPost) + require.Len(t, posts, 2) + require.Equal(t, float64(message.FlagComponentsV2), posts[0]["flags"]) + + components := posts[0]["components"].([]any) + require.Len(t, components, 3) + require.Equal(t, "Hello X", components[0].(map[string]any)["content"]) + require.Equal(t, "**Q**\na", components[1].(map[string]any)["content"]) + require.Equal(t, []string{"close", "close_with_reason", "claim"}, rowCustomIds(t, components[2])) + + require.NotContains(t, posts[1], "flags") + require.Len(t, posts[1]["embeds"], 2) + require.Equal(t, "subject", posts[1]["embeds"].([]any)[0].(map[string]any)["title"]) +} + +func TestSendWelcomeMessageClassicAnswersEmbed(t *testing.T) { + answers := map[database.FormInput]string{{Id: 1, Label: "Q"}: "answer"} + + tests := []struct { + name string + subject string + formData map[database.FormInput]string + wantEmbeds int + }{ + {"no answers", "subject", nil, 1}, + {"answers get their own embed", "subject", answers, 2}, + {"no answer fits beside the embed", strings.Repeat("s", embedTotalLimit), answers, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + transport := captureDiscordRequests(t, `{"id":"42"}`) + + cmd := welcomeCommandContext{premiumCommandContext{tier: premium.Premium}} + _, err := SendWelcomeMessage(context.Background(), cmd, welcomeTicket(), tt.subject, nil, tt.formData, nil) + require.NoError(t, err) + + posts := transport.bodies(http.MethodPost) + require.Len(t, posts, 1) + require.Len(t, posts[0]["embeds"], tt.wantEmbeds) + }) + } +} + +func TestUpdateWelcomeMessageClaimButton(t *testing.T) { + row := func(toggle string) string { + return `{"type":1,"components":[{"type":2,"style":4,"label":"close","custom_id":"close"},{"type":2,"style":3,"label":"` + toggle + `","custom_id":"` + toggle + `"}]}` + } + classic := func(toggle string) string { + return `{"id":"2","channel_id":"1","embeds":[{"title":"welcome"}],"components":[` + row(toggle) + `]}` + } + v2 := func(toggle string) string { + return `{"id":"2","channel_id":"1","flags":32768,"components":[{"type":10,"content":"welcome"},` + row(toggle) + `]}` + } + + tests := []struct { + name string + msg string + claimed bool + want string + }{ + {"classic claim", classic("claim"), true, "unclaim"}, + {"classic unclaim", classic("unclaim"), false, "claim"}, + {"v2 claim", v2("claim"), true, "unclaim"}, + {"v2 unclaim", v2("unclaim"), false, "claim"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + transport := captureDiscordRequests(t, tt.msg) + + channelId, messageId := uint64(1), uint64(2) + ticket := database.Ticket{ChannelId: &channelId, WelcomeMessageId: &messageId} + require.NoError(t, UpdateWelcomeMessageClaimButton(context.Background(), &worker.Context{}, messageCommandContext{}, ticket, tt.claimed)) + + patches := transport.bodies(http.MethodPatch) + require.Len(t, patches, 1) + + components := patches[0]["components"].([]any) + require.Equal(t, []string{"close", tt.want}, rowCustomIds(t, components[len(components)-1])) + }) + } +} From e105ce7084d3c7bfe9e748520795a7788b367e71 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:39:41 +0100 Subject: [PATCH 07/13] Wrap welcome answers in a container Place V2 welcome message answer displays inside a container to preserve the dashboard component reservation and update related budget and payload tests. --- bot/logic/welcomemessage.go | 17 +++++--- bot/logic/welcomemessage_limits_test.go | 39 +++++++++++++------ bot/logic/welcomemessage_v2_test.go | 52 ++++++++++++++++++++++--- 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/bot/logic/welcomemessage.go b/bot/logic/welcomemessage.go index 1f38c77e..a371ba4a 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -246,14 +246,19 @@ func buildWelcomeV2Components(tree []component.Component, fields []embed.EmbedFi tree, used := fitTemplateText(tree, v2TextDisplayBudget-reserve) allowances := waterFill(needs, v2TextDisplayBudget-used) - out := make([]component.Component, 0, len(tree)+len(fields)+1) + out := make([]component.Component, 0, len(tree)+2) out = append(out, tree...) - 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] + value, - })) + if len(fields) > 0 { + answers := make([]component.Component, len(fields)) + for i, field := range fields { + value := truncateEscaped(field.Value, allowances[i]-utf8.RuneCountInString(headers[i])) + answers[i] = component.BuildTextDisplay(component.TextDisplay{ + Content: headers[i] + value, + }) + } + + out = append(out, component.BuildContainer(component.Container{Components: answers})) } if len(buttons) > 0 { diff --git a/bot/logic/welcomemessage_limits_test.go b/bot/logic/welcomemessage_limits_test.go index 39e74636..0687d91d 100644 --- a/bot/logic/welcomemessage_limits_test.go +++ b/bot/logic/welcomemessage_limits_test.go @@ -128,6 +128,18 @@ func answerFields(values ...string) []embed.EmbedField { return fields } +func answerDisplays(t *testing.T, got []component.Component, templateLen int) []component.Component { + t.Helper() + if len(got) == templateLen { + return nil + } + + require.Len(t, got, templateLen+1) + container, ok := got[templateLen].ComponentData.(component.Container) + require.True(t, ok) + return container.Components +} + func TestBuildWelcomeV2ComponentsBudget(t *testing.T) { header := runeLen(v2AnswerHeader(embed.EmbedField{Name: "Q"})) long := strings.Repeat("x", 4000) @@ -246,11 +258,11 @@ func TestBuildWelcomeV2ComponentsBudget(t *testing.T) { 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) + answers := answerDisplays(t, got, len(tt.template)) + require.Len(t, answers, tt.wantAnswers) truncated := false - for _, c := range appended { + for _, c := range answers { content := c.ComponentData.(component.TextDisplay).Content require.True(t, strings.HasPrefix(content, "**Q**\n")) if strings.HasSuffix(content, truncationEllipsis) { @@ -267,15 +279,16 @@ 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) + require.Equal(t, "**Q**\nkeep me", answerDisplays(t, got, 1)[1].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)) + answers := answerDisplays(t, got, 1) + require.Equal(t, "**Q**\nbuilderman", answers[0].ComponentData.(component.TextDisplay).Content) + require.Equal(t, v2AnswerFloor, runeLen(answers[1].ComponentData.(component.TextDisplay).Content)) require.Equal(t, v2TextDisplayBudget, totalTextDisplayLength(got)) } @@ -293,9 +306,10 @@ func TestBuildWelcomeV2ComponentsAnswerFloorWithMixedLabels(t *testing.T) { 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)) + answers := answerDisplays(t, got, 1) + require.Equal(t, "**Q**\n"+short, answers[2].ComponentData.(component.TextDisplay).Content) + for _, i := range []int{0, 1, 3, 4} { + require.Equal(t, v2AnswerFloor, runeLen(answers[i].ComponentData.(component.TextDisplay).Content)) } require.Equal(t, v2TextDisplayBudget, totalTextDisplayLength(got)) } @@ -331,9 +345,10 @@ func TestBuildWelcomeV2ComponentsEscapedLabelBudget(t *testing.T) { got := buildWelcomeV2Components([]component.Component{textDisplay(strings.Repeat("w", 3990))}, fields, nil) - require.Len(t, got, 6) + answers := answerDisplays(t, got, 1) + require.Len(t, answers, 5) require.LessOrEqual(t, totalTextDisplayLength(got), v2TextDisplayBudget) - for _, c := range got[1:] { + for _, c := range answers { require.True(t, strings.HasPrefix(c.ComponentData.(component.TextDisplay).Content, header)) } } @@ -341,7 +356,7 @@ func TestBuildWelcomeV2ComponentsEscapedLabelBudget(t *testing.T) { func TestBuildWelcomeV2ComponentsKeepsEscapedShortAnswerWhole(t *testing.T) { got := buildWelcomeV2Components(nil, []embed.EmbedField{{Name: "Order **ID**", Value: "answer"}}, nil) - require.Equal(t, `**Order \*\*ID\*\***`+"\nanswer", got[0].ComponentData.(component.TextDisplay).Content) + require.Equal(t, `**Order \*\*ID\*\***`+"\nanswer", answerDisplays(t, got, 0)[0].ComponentData.(component.TextDisplay).Content) } func TestFitTemplateText(t *testing.T) { diff --git a/bot/logic/welcomemessage_v2_test.go b/bot/logic/welcomemessage_v2_test.go index a7e062dd..4b66b334 100644 --- a/bot/logic/welcomemessage_v2_test.go +++ b/bot/logic/welcomemessage_v2_test.go @@ -166,9 +166,9 @@ func TestBuildWelcomeV2ComponentsAppendsEveryAnswer(t *testing.T) { }{ {"no answers", 0, buttons, 2}, {"no answers or buttons", 0, nil, 1}, - {"under the old budget", 3, buttons, 5}, - {"over the old budget", 12, buttons, 14}, - {"no buttons", 12, nil, 13}, + {"under the old budget", 3, buttons, 3}, + {"over the old budget", 12, buttons, 3}, + {"no buttons", 12, nil, 2}, } for _, tt := range tests { @@ -180,6 +180,9 @@ func TestBuildWelcomeV2ComponentsAppendsEveryAnswer(t *testing.T) { got := buildWelcomeV2Components(authored, fields, tt.buttons) require.Len(t, got, tt.wantTotal) + if tt.answers > 0 { + require.Len(t, got[1].ComponentData.(component.Container).Components, tt.answers) + } if len(tt.buttons) > 0 { require.Equal(t, component.ComponentActionRow, got[len(got)-1].Type) @@ -188,6 +191,33 @@ func TestBuildWelcomeV2ComponentsAppendsEveryAnswer(t *testing.T) { } } +func countComponents(components []component.Component) int { + n := 0 + for _, c := range components { + n++ + switch d := c.ComponentData.(type) { + case component.Container: + n += countComponents(d.Components) + case component.ActionRow: + n += countComponents(d.Components) + case component.Section: + n += countComponents(d.Components) + 1 + } + } + return n +} + +func TestBuildWelcomeV2ComponentsUsesTheDashboardReservation(t *testing.T) { + buttons := []component.Component{ + component.BuildButton(component.Button{CustomId: "close"}), + component.BuildButton(component.Button{CustomId: "close_with_reason"}), + component.BuildButton(component.Button{CustomId: "claim"}), + } + + got := buildWelcomeV2Components(nil, answerFields("1", "2", "3", "4", "5"), buttons) + require.Equal(t, 10, countComponents(got)) +} + func v2ClaimRow() component.Component { return component.BuildActionRow( component.BuildButton(component.Button{CustomId: "close"}), @@ -486,6 +516,18 @@ func welcomeTicket() database.Ticket { return database.Ticket{Id: 5, GuildId: 1, UserId: 80351110224678912, ChannelId: &channelId} } +func containerTexts(t *testing.T, raw any) []string { + t.Helper() + container := raw.(map[string]any) + require.Equal(t, float64(component.ComponentContainer), container["type"]) + + var texts []string + for _, child := range container["components"].([]any) { + texts = append(texts, child.(map[string]any)["content"].(string)) + } + return texts +} + func TestSendWelcomeMessageV2Payload(t *testing.T) { transport := captureDiscordRequests(t, `{"id":"42"}`) @@ -508,7 +550,7 @@ func TestSendWelcomeMessageV2Payload(t *testing.T) { components := body["components"].([]any) require.Len(t, components, 3) require.Equal(t, "Hello X <@80351110224678912>", components[0].(map[string]any)["content"]) - require.Equal(t, "**Q**\n@everyone <@&123> %custom% %user%", components[1].(map[string]any)["content"], "form answers are never substituted") + require.Equal(t, "**Q**\n@everyone <@&123> %custom% %user%", containerTexts(t, components[1])[0], "form answers are never substituted") require.Equal(t, []string{"close", "close_with_reason", "claim"}, rowCustomIds(t, components[2])) } @@ -535,7 +577,7 @@ func TestSendWelcomeMessageFallsBackToClassic(t *testing.T) { components := posts[0]["components"].([]any) require.Len(t, components, 3) require.Equal(t, "Hello X", components[0].(map[string]any)["content"]) - require.Equal(t, "**Q**\na", components[1].(map[string]any)["content"]) + require.Equal(t, []string{"**Q**\na"}, containerTexts(t, components[1])) require.Equal(t, []string{"close", "close_with_reason", "claim"}, rowCustomIds(t, components[2])) require.NotContains(t, posts[1], "flags") From 54a9e3c54c74d1ded3f98df94aa7d8497fd830d3 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:46:17 +0100 Subject: [PATCH 08/13] Preserve welcome answer accent colors Carry the last container's accent color onto the generated answers container and add coverage for colored, uncolored, and missing-container cases. --- bot/logic/welcomemessage.go | 15 ++++++++++++++- bot/logic/welcomemessage_v2_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/bot/logic/welcomemessage.go b/bot/logic/welcomemessage.go index a371ba4a..55099948 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -258,7 +258,10 @@ func buildWelcomeV2Components(tree []component.Component, fields []embed.EmbedFi }) } - out = append(out, component.BuildContainer(component.Container{Components: answers})) + out = append(out, component.BuildContainer(component.Container{ + AccentColor: lastContainerAccent(tree), + Components: answers, + })) } if len(buttons) > 0 { @@ -268,6 +271,16 @@ func buildWelcomeV2Components(tree []component.Component, fields []embed.EmbedFi return out } +func lastContainerAccent(tree []component.Component) *int { + for i := len(tree) - 1; i >= 0; i-- { + if container, ok := tree[i].ComponentData.(component.Container); ok { + return container.AccentColor + } + } + + return nil +} + type componentTextTransform struct { Text func(string) string Label func(string) string diff --git a/bot/logic/welcomemessage_v2_test.go b/bot/logic/welcomemessage_v2_test.go index 4b66b334..db78662b 100644 --- a/bot/logic/welcomemessage_v2_test.go +++ b/bot/logic/welcomemessage_v2_test.go @@ -191,6 +191,34 @@ func TestBuildWelcomeV2ComponentsAppendsEveryAnswer(t *testing.T) { } } +func TestBuildWelcomeV2ComponentsAnswersAccent(t *testing.T) { + container := func(accent *int) component.Component { + return component.BuildContainer(component.Container{ + AccentColor: accent, + Components: []component.Component{textDisplay("welcome")}, + }) + } + + tests := []struct { + name string + tree []component.Component + want *int + }{ + {"no container", []component.Component{textDisplay("welcome")}, nil}, + {"container without accent", []component.Component{container(nil)}, nil}, + {"black is kept", []component.Component{container(utils.Ptr(0))}, utils.Ptr(0)}, + {"last container wins", []component.Component{container(utils.Ptr(0xff0000)), container(utils.Ptr(0x5865f2)), textDisplay("-# footer")}, utils.Ptr(0x5865f2)}, + {"uncoloured last container", []component.Component{container(utils.Ptr(0xff0000)), container(nil)}, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildWelcomeV2Components(tt.tree, answerFields("a"), nil) + require.Equal(t, tt.want, got[len(got)-1].ComponentData.(component.Container).AccentColor) + }) + } +} + func countComponents(components []component.Component) int { n := 0 for _, c := range components { From 88dadd1cc8ee4ce0fb38efbeca0ac115ccbbbb96 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:10:45 +0100 Subject: [PATCH 09/13] Harden welcome message delivery Add nonce-based retries for V2 welcome messages, prevent duplicate classic fallbacks, resolve mentionable form selections correctly, and enforce embed limits for survey responses. --- bot/button/handlers/exitsurveysubmit.go | 2 +- bot/button/handlers/form.go | 30 +++++++++-- bot/button/handlers/form_test.go | 30 +++++++++++ bot/button/handlers/viewsurvey.go | 7 ++- bot/logic/open.go | 6 ++- bot/logic/welcomemessage.go | 42 ++++++++++++--- bot/logic/welcomemessage_limits.go | 6 +++ bot/logic/welcomemessage_limits_test.go | 28 ++++++++++ bot/logic/welcomemessage_v2_test.go | 69 ++++++++++++++++++++++++- 9 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 bot/button/handlers/form_test.go diff --git a/bot/button/handlers/exitsurveysubmit.go b/bot/button/handlers/exitsurveysubmit.go index 3776af7b..f80c19a5 100644 --- a/bot/button/handlers/exitsurveysubmit.go +++ b/bot/button/handlers/exitsurveysubmit.go @@ -129,7 +129,7 @@ func (h *ExitSurveySubmitHandler) Execute(cmd *cmdcontext.ModalContext) { inputsByCustomId[input.CustomId] = input } - formAnswers := parseModalComponents(cmd.Interaction.Data.Components, inputsByCustomId) + formAnswers := parseModalComponents(cmd.Interaction.Data, inputsByCustomId) if invalidLabel, valid := validateFormAnswers(formAnswers); !valid { cmd.Reply(customisation.Red, i18n.Error, i18n.MessageFormMissingInput, invalidLabel) diff --git a/bot/button/handlers/form.go b/bot/button/handlers/form.go index 335fe00c..3e3031fc 100644 --- a/bot/button/handlers/form.go +++ b/bot/button/handlers/form.go @@ -2,10 +2,12 @@ package handlers import ( "fmt" + "strconv" "strings" "github.com/TicketsBot-cloud/common/sentry" "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/objects" "github.com/TicketsBot-cloud/gdl/objects/interaction" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/worker/bot/button/registry" @@ -67,7 +69,7 @@ func (h *FormHandler) Execute(ctx *context.ModalContext) { return } - formAnswers := parseModalComponents(data.Components, inputs) + formAnswers := parseModalComponents(data, inputs) if invalidLabel, valid := validateFormAnswers(formAnswers); !valid { ctx.Reply(customisation.Red, i18n.Error, i18n.MessageFormMissingInput, invalidLabel) @@ -82,9 +84,9 @@ func (h *FormHandler) Execute(ctx *context.ModalContext) { } // parseModalComponents extracts answers from modal action rows, keyed by the matching FormInput. -func parseModalComponents(actionRows []interaction.ModalSubmitInteractionActionRowData, inputsByCustomId map[string]database.FormInput) map[database.FormInput]string { +func parseModalComponents(data interaction.ModalSubmitInteractionData, inputsByCustomId map[string]database.FormInput) map[database.FormInput]string { answers := make(map[database.FormInput]string) - for _, actionRow := range actionRows { + for _, actionRow := range data.Components { if actionRow.Component != nil { c := actionRow.Component var answer string @@ -98,7 +100,7 @@ func parseModalComponents(actionRows []interaction.ModalSubmitInteractionActionR case component.ComponentRoleSelect: answer = joinMentions(c.Values, "role") case component.ComponentMentionableSelect: - answer = strings.Trim(strings.Join(c.Values, ", "), "<@&!>") + answer = joinMentionables(c.Values, data.Resolved) case component.ComponentChannelSelect: answer = joinMentions(c.Values, "channel") case component.ComponentRadioGroup: @@ -162,3 +164,23 @@ func joinMentions(mentions []string, mentionType string) string { return val } + +func joinMentionables(ids []string, resolved interaction.ResolvedData) string { + mentions := make([]string, len(ids)) + for i, id := range ids { + mentions[i] = id + + snowflake, err := strconv.ParseUint(id, 10, 64) + if err != nil { + continue + } + + if _, ok := resolved.Roles[objects.Snowflake(snowflake)]; ok { + mentions[i] = fmt.Sprintf("<@&%s>", id) + } else if _, ok := resolved.Users[objects.Snowflake(snowflake)]; ok { + mentions[i] = fmt.Sprintf("<@%s>", id) + } + } + + return strings.Join(mentions, ", ") +} diff --git a/bot/button/handlers/form_test.go b/bot/button/handlers/form_test.go new file mode 100644 index 00000000..85e013b2 --- /dev/null +++ b/bot/button/handlers/form_test.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "encoding/json" + "testing" + + "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/objects/interaction" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/stretchr/testify/require" +) + +func TestParseModalComponentsMentionableSelect(t *testing.T) { + raw := `{ + "custom_id": "form_panel", + "components": [{"type": 18, "id": 1, "component": {"type": 7, "id": 2, "custom_id": "who", "values": ["111", "222", "333"]}}], + "resolved": { + "users": {"111": {"id": "111", "username": "user"}}, + "members": {"111": {"roles": []}}, + "roles": {"222": {"id": "222", "name": "role"}} + } + }` + + var data interaction.ModalSubmitInteractionData + require.NoError(t, json.Unmarshal([]byte(raw), &data)) + + input := database.FormInput{Id: 1, CustomId: "who", Type: int(component.ComponentMentionableSelect)} + answers := parseModalComponents(data, map[string]database.FormInput{"who": input}) + require.Equal(t, "<@111>, <@&222>, 333", answers[input]) +} diff --git a/bot/button/handlers/viewsurvey.go b/bot/button/handlers/viewsurvey.go index 3b0129a5..222c5342 100644 --- a/bot/button/handlers/viewsurvey.go +++ b/bot/button/handlers/viewsurvey.go @@ -100,7 +100,8 @@ func (h *ViewSurveyHandler) Execute(ctx *context.ButtonContext) { SetAuthor(opener.Username, "", opener.AvatarUrl(256)). SetColorPtr(ctx.GetColour(customisation.Green)) - for _, answer := range surveyResponse.Responses { + fields := make([]embed.EmbedField, len(surveyResponse.Responses)) + for i, answer := range surveyResponse.Responses { var title string if answer.Question == nil || *answer.Question == "" { title = "Unknown Question" @@ -115,9 +116,11 @@ func (h *ViewSurveyHandler) Execute(ctx *context.ButtonContext) { response = "No response" } - e.AddField(title, response, false) + fields[i] = embed.EmbedField{Name: title, Value: response} } + logic.AddFormAnswerFields(e, fields) + var buttons []component.Component buttons = append(buttons, logic.TranscriptLinkElement(ticket.HasTranscript)(ctx.Worker(), ticket)...) buttons = append(buttons, logic.ThreadLinkElement(ticket.ChannelId != nil && ticket.IsThread)(ctx.Worker(), ticket)...) diff --git a/bot/logic/open.go b/bot/logic/open.go index 92e6ca04..bde9f78e 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -465,7 +465,11 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat // Update message IDs in DB span = sentry.StartSpan(rootSpan.Context(), "Update ticket properties in database") defer span.Finish() - if err := dbclient.Client.Tickets.SetMessageIds(ctx, cmd.GuildId(), ticketId, welcomeMessageId, joinMessageId); err != nil { + + // The welcome is already posted, so its id is stored even when slow sends used up the handler's deadline + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + if err := dbclient.Client.Tickets.SetMessageIds(dbCtx, cmd.GuildId(), ticketId, welcomeMessageId, joinMessageId); err != nil { return err } diff --git a/bot/logic/welcomemessage.go b/bot/logic/welcomemessage.go index 55099948..70eacf74 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -22,6 +22,7 @@ import ( "github.com/TicketsBot-cloud/gdl/objects/guild/emoji" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/gdl/rest" + "github.com/TicketsBot-cloud/gdl/rest/request" "github.com/TicketsBot-cloud/worker" "github.com/TicketsBot-cloud/worker/bot/command/registry" "github.com/TicketsBot-cloud/worker/bot/customisation" @@ -48,11 +49,16 @@ func SendWelcomeMessage( additionalPlaceholders map[string]string, ) (uint64, error) { if tree := loadWelcomeV2Tree(ctx, cmd, ticket, panel); tree != nil { - msgId, err := sendWelcomeMessageV2(ctx, cmd, ticket, panel, tree, formData, additionalPlaceholders) + msgId, rejected, err := sendWelcomeMessageV2(ctx, cmd, ticket, panel, tree, formData, additionalPlaceholders) if err == nil { return msgId, nil } + // A V2 message Discord didn't reject may still be posted, and a Classic one would then be a second welcome. + if !rejected { + return 0, err + } + logging.WarnWithContext(err, cmd.ToErrorContext(), zap.Uint64("guild_id", ticket.GuildId), zap.Int("panel_id", panel.PanelId)) } @@ -201,9 +207,9 @@ func sendWelcomeMessageV2( tree []component.Component, formData map[database.FormInput]string, additionalPlaceholders map[string]string, -) (uint64, error) { +) (msgId uint64, rejected bool, err error) { if ticket.ChannelId == nil { - return 0, fmt.Errorf("channel is nil") + return 0, true, fmt.Errorf("channel is nil") } tree = transformComponentText(tree, componentTextTransform{ @@ -221,16 +227,36 @@ func sendWelcomeMessageV2( finalTree := buildWelcomeV2Components(tree, getFormDataFields(formData), welcomeButtons(cmd, panel, ticket.IsThread)) data := rest.CreateMessageData{ - Flags: message.SumFlags(message.FlagComponentsV2), - Components: finalTree, + Flags: message.SumFlags(message.FlagComponentsV2), + Components: finalTree, + Nonce: "w" + strconv.FormatUint(*ticket.ChannelId, 10), + EnforceNonce: true, } msg, err := cmd.Worker().CreateMessageComplex(*ticket.ChannelId, data) - if err != nil { - return 0, err + if err == nil { + return msg.Id, false, nil } - return msg.Id, nil + if isRejection(err) { + return 0, true, err + } + + // The first send may still be posted; with the same nonce Discord returns that message instead of posting another. + time.Sleep(welcomeRetryDelay) + if msg, err = cmd.Worker().CreateMessageComplex(*ticket.ChannelId, data); err != nil { + return 0, false, err + } + + return msg.Id, false, nil +} + +var welcomeRetryDelay = 2 * time.Second + +// The http-proxy's own failures are 5xx, so a 4xx is Discord refusing the message. +func isRejection(err error) bool { + var restErr request.RestError + return errors.As(err, &restErr) && restErr.StatusCode >= 400 && restErr.StatusCode < 500 } func buildWelcomeV2Components(tree []component.Component, fields []embed.EmbedField, buttons []component.Component) []component.Component { diff --git a/bot/logic/welcomemessage_limits.go b/bot/logic/welcomemessage_limits.go index bac5ced0..f0000a7e 100644 --- a/bot/logic/welcomemessage_limits.go +++ b/bot/logic/welcomemessage_limits.go @@ -195,6 +195,12 @@ func fitClassicFormAnswers(fields []embed.EmbedField, used int) []embed.EmbedFie return out } +func AddFormAnswerFields(e *embed.Embed, fields []embed.EmbedField) { + for _, field := range fitClassicFormAnswers(fields, embedTextLength(e)) { + e.AddField(field.Name, field.Value, field.Inline) + } +} + func embedTextLength(e *embed.Embed) int { if e == nil { return 0 diff --git a/bot/logic/welcomemessage_limits_test.go b/bot/logic/welcomemessage_limits_test.go index 0687d91d..d1432955 100644 --- a/bot/logic/welcomemessage_limits_test.go +++ b/bot/logic/welcomemessage_limits_test.go @@ -562,6 +562,34 @@ func TestEmbedTextLength(t *testing.T) { require.Equal(t, 0, embedTextLength(nil)) } +func TestAddFormAnswerFields(t *testing.T) { + long := strings.Repeat("a", 3000) + + tests := []struct { + name string + values []string + }{ + {"one long answer", []string{long, "short"}}, + {"long answers past the embed total", []string{long, long, long, long, long, long, long, "*short*"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := embed.NewEmbed().SetTitle("Exit Survey").SetAuthor("opener", "", "") + AddFormAnswerFields(e, answerFields(tt.values...)) + + require.Len(t, e.Fields, len(tt.values)) + for _, f := range e.Fields { + require.LessOrEqual(t, runeLen(f.Value), embedFieldValueLimit) + } + + require.LessOrEqual(t, embedTextLength(e), embedTotalLimit) + require.True(t, strings.HasSuffix(e.Fields[0].Value, truncationEllipsis)) + require.Contains(t, e.Fields[len(e.Fields)-1].Value, "short") + }) + } +} + func TestGetFormDataFieldsBlankAnswers(t *testing.T) { tests := []struct { name string diff --git a/bot/logic/welcomemessage_v2_test.go b/bot/logic/welcomemessage_v2_test.go index db78662b..4586552f 100644 --- a/bot/logic/welcomemessage_v2_test.go +++ b/bot/logic/welcomemessage_v2_test.go @@ -3,6 +3,7 @@ package logic import ( "context" "encoding/json" + "errors" "io" "net/http" "strings" @@ -463,6 +464,8 @@ type captureTransport struct { requests []capturedRequest } +const transportFailure = -1 + func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) { captured := capturedRequest{method: req.Method} if req.Body != nil { @@ -485,6 +488,10 @@ func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) c.requests = append(c.requests, captured) + if status == transportFailure { + return nil, errors.New("timeout awaiting response headers") + } + return &http.Response{ StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, @@ -563,8 +570,9 @@ func TestSendWelcomeMessageV2Payload(t *testing.T) { formData := map[database.FormInput]string{{Id: 1, Label: "Q"}: "@everyone <@&123> %custom% %user%"} cmd := welcomeCommandContext{premiumCommandContext{tier: premium.Premium}} - id, err := sendWelcomeMessageV2(context.Background(), cmd, welcomeTicket(), &database.Panel{}, tree, formData, map[string]string{"custom": "X"}) + id, rejected, err := sendWelcomeMessageV2(context.Background(), cmd, welcomeTicket(), &database.Panel{}, tree, formData, map[string]string{"custom": "X"}) require.NoError(t, err) + require.False(t, rejected) require.Equal(t, uint64(42), id) posts := transport.bodies(http.MethodPost) @@ -573,6 +581,8 @@ func TestSendWelcomeMessageV2Payload(t *testing.T) { body := posts[0] require.Equal(t, float64(message.FlagComponentsV2), body["flags"]) require.Equal(t, map[string]any{"replied_user": false}, body["allowed_mentions"]) + require.Equal(t, "w7", body["nonce"]) + require.Equal(t, true, body["enforce_nonce"]) require.NotContains(t, body, "embeds") components := body["components"].([]any) @@ -613,6 +623,63 @@ func TestSendWelcomeMessageFallsBackToClassic(t *testing.T) { require.Equal(t, "subject", posts[1]["embeds"].([]any)[0].(map[string]any)["title"]) } +func TestSendWelcomeMessageV2Failures(t *testing.T) { + previousDelay := welcomeRetryDelay + welcomeRetryDelay = 0 + t.Cleanup(func() { welcomeRetryDelay = previousDelay }) + + previous := utils.PremiumClient + t.Cleanup(func() { utils.PremiumClient = previous }) + client := premium.NewMockLookupClient(premium.Premium, model.EntitlementSourceDiscord) + utils.PremiumClient = &client + + tests := []struct { + name string + statuses []int + wantV2 int + wantErr bool + }{ + {"rejected falls back to classic", []int{http.StatusBadRequest, http.StatusOK}, 1, false}, + {"rate limited falls back to classic", []int{http.StatusTooManyRequests, http.StatusOK}, 1, false}, + {"server error retries", []int{http.StatusInternalServerError, http.StatusOK}, 2, false}, + {"timeout retries", []int{transportFailure, http.StatusOK}, 2, false}, + {"failed retry never falls back", []int{http.StatusBadGateway, http.StatusBadGateway}, 2, true}, + {"rejected retry never falls back", []int{transportFailure, http.StatusBadRequest}, 2, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + transport := captureDiscordRequests(t, `{"id":"42"}`, tt.statuses...) + + panel := &database.Panel{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: strPtr(`[{"type":10,"content":"Hello"}]`)} + cmd := welcomeCommandContext{premiumCommandContext{tier: premium.Premium}} + + _, err := SendWelcomeMessage(context.Background(), cmd, welcomeTicket(), "subject", panel, nil, nil) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + posts := transport.bodies(http.MethodPost) + require.Len(t, posts, 2) + + v2 := 0 + for _, body := range posts { + if body["flags"] != float64(message.FlagComponentsV2) { + require.NotContains(t, body, "nonce") + continue + } + + v2++ + require.Equal(t, "w7", body["nonce"]) + require.Equal(t, true, body["enforce_nonce"]) + } + require.Equal(t, tt.wantV2, v2) + }) + } +} + func TestSendWelcomeMessageClassicAnswersEmbed(t *testing.T) { answers := map[database.FormInput]string{{Id: 1, Label: "Q"}: "answer"} From acf9323c5584498313f0d959a3ea08306cddb5e3 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:13:39 +0100 Subject: [PATCH 10/13] Split long welcome form answers Preserve full form answers by splitting values across Discord embed fields at natural boundaries while respecting field and embed limits. Adds coverage for continuation fields, escaping, Unicode, and property-based limits. --- bot/logic/welcomemessage_limits.go | 103 ++++++++++++++-- bot/logic/welcomemessage_limits_test.go | 151 +++++++++++++++++++++--- bot/logic/welcomemessage_v2_test.go | 24 ++++ 3 files changed, 254 insertions(+), 24 deletions(-) diff --git a/bot/logic/welcomemessage_limits.go b/bot/logic/welcomemessage_limits.go index f0000a7e..4b2c2590 100644 --- a/bot/logic/welcomemessage_limits.go +++ b/bot/logic/welcomemessage_limits.go @@ -3,6 +3,7 @@ package logic import ( "sort" "strings" + "unicode" "unicode/utf8" "github.com/TicketsBot-cloud/gdl/objects/channel/embed" @@ -18,6 +19,10 @@ const ( embedFieldValueLimit = 1024 embedTotalLimit = 6000 + // Discord needs a field name, and a zero-width space shows as a blank line, like a paragraph break. + continuationFieldName = "\u200b" + minAnswerChunk = embedFieldValueLimit / 2 + truncationEllipsis = "…" ) @@ -85,7 +90,7 @@ func waterFill(lengths []int, budget int) []int { return out } -func fitAnswerValues(values []string, overheads []int, budget, perLimit int) []string { +func fitAnswerValues(values []string, overheads []int, budget int) []string { kept := 0 used := 0 for i := range values { @@ -101,10 +106,6 @@ func fitAnswerValues(values []string, overheads []int, budget, perLimit int) []s 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] } @@ -185,16 +186,100 @@ func fitClassicFormAnswers(fields []embed.EmbedField, used int) []embed.EmbedFie overheads[i] = utf8.RuneCountInString(names[i]) } - fitted := fitAnswerValues(values, overheads, embedTotalLimit-used, embedFieldValueLimit) + // Continuation names count towards the total too, so refit until enough room is left for them. + var parts [][]string + for reserved := 0; ; { + fitted := fitAnswerValues(values, overheads, embedTotalLimit-used-reserved) + + parts = make([][]string, len(fitted)) + continuations := 0 + for i, value := range fitted { + parts[i] = splitAnswer(value) + continuations += len(parts[i]) - 1 + } + + if continuations <= reserved { + break + } - out := make([]embed.EmbedField, len(fitted)) - for i, value := range fitted { - out[i] = embed.EmbedField{Name: names[i], Value: value, Inline: fields[i].Inline} + reserved = continuations + } + + var out []embed.EmbedField + for i, chunks := range parts { + for j, chunk := range chunks { + name := names[i] + if j > 0 { + name = continuationFieldName + } + + out = append(out, embed.EmbedField{Name: name, Value: chunk, Inline: fields[i].Inline}) + } } return out } +func splitAnswer(value string) []string { + runes := []rune(value) + if len(runes) <= embedFieldValueLimit { + return []string{value} + } + + var chunks []string + for len(runes) > embedFieldValueLimit { + cut := answerCut(runes[:embedFieldValueLimit+1]) + if chunk := strings.TrimRightFunc(string(runes[:cut]), unicode.IsSpace); chunk != "" { + chunks = append(chunks, chunk) + } + + runes = []rune(strings.TrimLeftFunc(string(runes[cut:]), unicode.IsSpace)) + } + + if rest := strings.TrimRightFunc(string(runes), unicode.IsSpace); rest != "" { + chunks = append(chunks, rest) + } + + return chunks +} + +// Continue at the most natural break, but not before half a field, so an answer doesn't become many small blocks. +func answerCut(window []rune) int { + limit := len(window) - 1 + for _, isBreak := range []func(r []rune, i int) bool{isParagraphBreak, isLineBreak, isSentenceEnd, isWordBreak} { + for i := limit; i >= minAnswerChunk; i-- { + if isBreak(window, i) { + return i + } + } + } + + // An escaping backslash stays with the character it escapes, or the next block would start unescaped. + cut := limit + if trailingBackslashes(window[:cut])%2 == 1 { + cut-- + } + + return cut +} + +func isParagraphBreak(r []rune, i int) bool { return r[i] == '\n' && r[i-1] == '\n' } +func isLineBreak(r []rune, i int) bool { return r[i] == '\n' } +func isWordBreak(r []rune, i int) bool { return unicode.IsSpace(r[i]) } + +func isSentenceEnd(r []rune, i int) bool { + return strings.ContainsRune("γ€‚οΌοΌŸ", r[i-1]) || unicode.IsSpace(r[i]) && strings.ContainsRune(".!?", r[i-1]) +} + +func trailingBackslashes(r []rune) int { + n := 0 + for i := len(r) - 1; i >= 0 && r[i] == '\\'; i-- { + n++ + } + + return n +} + func AddFormAnswerFields(e *embed.Embed, fields []embed.EmbedField) { for _, field := range fitClassicFormAnswers(fields, embedTextLength(e)) { e.AddField(field.Name, field.Value, field.Inline) diff --git a/bot/logic/welcomemessage_limits_test.go b/bot/logic/welcomemessage_limits_test.go index d1432955..779c43ff 100644 --- a/bot/logic/welcomemessage_limits_test.go +++ b/bot/logic/welcomemessage_limits_test.go @@ -1,6 +1,7 @@ package logic import ( + "math/rand" "slices" "strings" "testing" @@ -9,6 +10,7 @@ import ( "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/channel/embed" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/TicketsBot-cloud/worker/bot/utils" "github.com/stretchr/testify/require" ) @@ -472,30 +474,54 @@ func TestFitClassicFormAnswers(t *testing.T) { }, }, { - name: "one over field limit", + name: "one over field limit continues", fields: []embed.EmbedField{{Name: "Q", Value: strings.Repeat("a", embedFieldValueLimit+1)}}, - wantCount: 1, + wantCount: 2, 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)) + require.Equal(t, embed.EmbedField{Name: "Q", Value: strings.Repeat("a", embedFieldValueLimit)}, got[0]) + require.Equal(t, embed.EmbedField{Name: continuationFieldName, Value: "a"}, got[1]) }, }, { name: "escaping pushes over field limit", fields: []embed.EmbedField{{Name: "Q", Value: strings.Repeat("*", 600)}}, - wantCount: 1, + wantCount: 2, 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), `\`)) + require.Equal(t, strings.Repeat(`\*`, 600), got[0].Value+got[1].Value) }, }, { name: "multibyte answer", fields: []embed.EmbedField{{Name: "Q", Value: strings.Repeat("πŸ˜€", 2000)}}, - wantCount: 1, + wantCount: 2, check: func(t *testing.T, got []embed.EmbedField) { require.Equal(t, embedFieldValueLimit, runeLen(got[0].Value)) - require.True(t, utf8.ValidString(got[0].Value)) + require.Equal(t, strings.Repeat("πŸ˜€", 2000), got[0].Value+got[1].Value) + }, + }, + { + name: "long answer shown in full", + fields: []embed.EmbedField{{Name: "Q1", Value: long}, {Name: "Q2", Value: "short"}}, + wantCount: 5, + check: func(t *testing.T, got []embed.EmbedField) { + names := []string{"Q1", continuationFieldName, continuationFieldName, continuationFieldName, "Q2"} + var text string + for i, f := range got { + require.Equal(t, names[i], f.Name) + if i < 4 { + text += f.Value + } + } + require.Equal(t, long, text) + require.Equal(t, "short", got[4].Value) + }, + }, + { + name: "continuation names count towards the total", + fields: []embed.EmbedField{{Name: "Q", Value: strings.Repeat("a", embedTotalLimit)}}, + wantCount: 6, + check: func(t *testing.T, got []embed.EmbedField) { + require.True(t, strings.HasSuffix(got[5].Value, truncationEllipsis)) }, }, { @@ -554,6 +580,101 @@ func TestFitClassicFormAnswers(t *testing.T) { } } +func TestSplitAnswer(t *testing.T) { + a := func(n int) string { return strings.Repeat("a", n) } + words := func(word string, n int) string { return strings.TrimSpace(strings.Repeat(word+" ", n)) } + + tests := []struct { + name string + value string + want []string + }{ + {"fits unchanged", a(1020) + " ", []string{a(1020) + " "}}, + {"paragraph break first", a(600) + "\n\n" + words("b", 300), []string{a(600), words("b", 300)}}, + {"line break", a(600) + "\n" + words("b", 300), []string{a(600), words("b", 300)}}, + {"sentence end", a(598) + ". " + words("b", 300), []string{a(598) + ".", words("b", 300)}}, + {"sentence end without a space", strings.Repeat("ζ—₯", 600) + "。" + strings.Repeat("本", 600), []string{strings.Repeat("ζ—₯", 600) + "。", strings.Repeat("本", 600)}}, + {"word break", words("ab", 400), []string{words("ab", 341), words("ab", 59)}}, + {"break too early is skipped", a(100) + "\n\n" + words("c", 600), []string{a(100) + "\n\n" + words("c", 461), words("c", 139)}}, + {"escape stays together", a(1023) + `\*` + a(100), []string{a(1023), `\*` + a(100)}}, + {"escaped backslash splits normally", a(1022) + `\\` + a(100), []string{a(1022) + `\\`, a(100)}}, + {"blank block dropped", strings.Repeat(" ", 1100) + "x", []string{"x"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, splitAnswer(tt.value)) + }) + } +} + +func randomAnswer(rng *rand.Rand, maxLen int) string { + tokens := []string{"word", "x", " ", " ", "\n", "\n\n", ". ", "!", "。", "*", "_", "#", "πŸ˜€", "ζ—₯本θͺž", "<@123456789012345678>", "https://example.com/" + strings.Repeat("p", 40)} + + var b strings.Builder + target := rng.Intn(maxLen) + for runeLen(b.String()) < target { + b.WriteString(tokens[rng.Intn(len(tokens))]) + } + + return b.String() +} + +func withoutSpace(s string) string { + return strings.Join(strings.Fields(s), "") +} + +func TestSplitAnswerProperties(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + for range 1000 { + value := utils.EscapeMarkdown(randomAnswer(rng, 6000)) + chunks := splitAnswer(value) + if runeLen(value) <= embedFieldValueLimit { + require.Equal(t, []string{value}, chunks) + continue + } + + for _, chunk := range chunks { + require.NotEmpty(t, strings.TrimSpace(chunk)) + require.LessOrEqual(t, runeLen(chunk), embedFieldValueLimit) + require.Zero(t, trailingBackslashes([]rune(chunk))%2, "an escape was split from its character") + } + + require.Equal(t, withoutSpace(value), withoutSpace(strings.Join(chunks, "")), "text lost or repeated") + } +} + +func TestFitClassicFormAnswersProperties(t *testing.T) { + rng := rand.New(rand.NewSource(2)) + for range 1000 { + fields := make([]embed.EmbedField, 1+rng.Intn(5)) + for i := range fields { + fields[i] = embed.EmbedField{Name: "Question " + strings.Repeat("?", rng.Intn(45)), Value: randomAnswer(rng, 4000) + "x"} + } + + used := rng.Intn(embedTotalLimit) + got := fitClassicFormAnswers(fields, used) + require.LessOrEqual(t, len(got), 25) + + total := used + answers := 0 + for _, f := range got { + require.NotEmpty(t, f.Value) + require.LessOrEqual(t, runeLen(f.Value), embedFieldValueLimit) + if f.Name != continuationFieldName { + require.Equal(t, fields[answers].Name, f.Name) + answers++ + } + total += runeLen(f.Name) + runeLen(f.Value) + } + + if len(got) > 0 { + require.NotEqual(t, continuationFieldName, got[0].Name) + require.LessOrEqual(t, total, embedTotalLimit) + } + } +} + func TestEmbedTextLength(t *testing.T) { e := embed.NewEmbed().SetTitle("ab").SetDescription("ζ—₯本").SetFooter("foot", "").SetAuthor("me", "", "") e.AddField("n", "v", false) @@ -566,11 +687,12 @@ func TestAddFormAnswerFields(t *testing.T) { long := strings.Repeat("a", 3000) tests := []struct { - name string - values []string + name string + values []string + wantFields int }{ - {"one long answer", []string{long, "short"}}, - {"long answers past the embed total", []string{long, long, long, long, long, long, long, "*short*"}}, + {"long answer continues", []string{long, "short"}, 4}, + {"long answers past the embed total", []string{long, long, long, long, long, long, long, "*short*"}, 8}, } for _, tt := range tests { @@ -578,13 +700,12 @@ func TestAddFormAnswerFields(t *testing.T) { e := embed.NewEmbed().SetTitle("Exit Survey").SetAuthor("opener", "", "") AddFormAnswerFields(e, answerFields(tt.values...)) - require.Len(t, e.Fields, len(tt.values)) + require.Len(t, e.Fields, tt.wantFields) for _, f := range e.Fields { require.LessOrEqual(t, runeLen(f.Value), embedFieldValueLimit) } require.LessOrEqual(t, embedTextLength(e), embedTotalLimit) - require.True(t, strings.HasSuffix(e.Fields[0].Value, truncationEllipsis)) require.Contains(t, e.Fields[len(e.Fields)-1].Value, "short") }) } diff --git a/bot/logic/welcomemessage_v2_test.go b/bot/logic/welcomemessage_v2_test.go index 4586552f..5c32eb4f 100644 --- a/bot/logic/welcomemessage_v2_test.go +++ b/bot/logic/welcomemessage_v2_test.go @@ -709,6 +709,30 @@ func TestSendWelcomeMessageClassicAnswersEmbed(t *testing.T) { } } +func TestSendWelcomeMessageClassicLongAnswer(t *testing.T) { + transport := captureDiscordRequests(t, `{"id":"42"}`) + + answer := strings.Repeat("word ", 600) + formData := map[database.FormInput]string{{Id: 1, Label: "Q"}: answer} + cmd := welcomeCommandContext{premiumCommandContext{tier: premium.Premium}} + + _, err := SendWelcomeMessage(context.Background(), cmd, welcomeTicket(), "subject", nil, formData, nil) + require.NoError(t, err) + + embeds := transport.bodies(http.MethodPost)[0]["embeds"].([]any) + require.Len(t, embeds, 2) + + var names, values []string + for _, raw := range embeds[1].(map[string]any)["fields"].([]any) { + field := raw.(map[string]any) + names = append(names, field["name"].(string)) + values = append(values, field["value"].(string)) + } + + require.Equal(t, []string{"Q", continuationFieldName, continuationFieldName}, names) + require.Equal(t, strings.TrimSpace(answer), strings.Join(values, " ")) +} + func TestUpdateWelcomeMessageClaimButton(t *testing.T) { row := func(toggle string) string { return `{"type":1,"components":[{"type":2,"style":4,"label":"close","custom_id":"close"},{"type":2,"style":3,"label":"` + toggle + `","custom_id":"` + toggle + `"}]}` From cc97f4d37fae0a23cdb618ef703cbca067d12a58 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:19:05 +0100 Subject: [PATCH 11/13] Handle Unicode close reason limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Count close reasons by Unicode runes instead of bytes and truncate embed text safely to Discord’s field limit. --- bot/button/handlers/closewithreasonsubmit.go | 3 ++- bot/button/handlers/editclosereasonsubmit.go | 3 ++- bot/command/impl/tickets/closerequest.go | 3 ++- bot/logic/closeembed.go | 5 +---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/button/handlers/closewithreasonsubmit.go b/bot/button/handlers/closewithreasonsubmit.go index c499225a..d19d7bf9 100644 --- a/bot/button/handlers/closewithreasonsubmit.go +++ b/bot/button/handlers/closewithreasonsubmit.go @@ -3,6 +3,7 @@ package handlers import ( "fmt" "strings" + "unicode/utf8" "github.com/TicketsBot-cloud/worker/bot/button/registry" "github.com/TicketsBot-cloud/worker/bot/button/registry/matcher" @@ -43,7 +44,7 @@ func (h *CloseWithReasonSubmitHandler) Execute(ctx *context.ModalContext) { } // This must be malicious - if len(reason) > 1024 { + if utf8.RuneCountInString(reason) > 1024 { ctx.HandleError(fmt.Errorf("Reason is too long")) return } diff --git a/bot/button/handlers/editclosereasonsubmit.go b/bot/button/handlers/editclosereasonsubmit.go index 62c2930c..ce9b9917 100644 --- a/bot/button/handlers/editclosereasonsubmit.go +++ b/bot/button/handlers/editclosereasonsubmit.go @@ -5,6 +5,7 @@ import ( "regexp" "strconv" "strings" + "unicode/utf8" "github.com/TicketsBot-cloud/common/permission" "github.com/TicketsBot-cloud/database" @@ -79,7 +80,7 @@ func (h *EditCloseReasonSubmitHandler) Execute(ctx *context.ModalContext) { } } - if len(reason) > 1024 { + if utf8.RuneCountInString(reason) > 1024 { ctx.HandleError(fmt.Errorf("reason too long")) return } diff --git a/bot/command/impl/tickets/closerequest.go b/bot/command/impl/tickets/closerequest.go index 367a87c9..4b58d3a3 100644 --- a/bot/command/impl/tickets/closerequest.go +++ b/bot/command/impl/tickets/closerequest.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" "time" + "unicode/utf8" "github.com/TicketsBot-cloud/common/model" "github.com/TicketsBot-cloud/common/permission" @@ -60,7 +61,7 @@ func (CloseRequestCommand) Execute(ctx registry.CommandContext, closeDelay *int, return } - if reason != nil && len(*reason) > 255 { + if reason != nil && utf8.RuneCountInString(*reason) > 255 { ctx.Reply(customisation.Red, i18n.Error, i18n.MessageCloseReasonTooLong) return } diff --git a/bot/logic/closeembed.go b/bot/logic/closeembed.go index 805de98c..59ad68e0 100644 --- a/bot/logic/closeembed.go +++ b/bot/logic/closeembed.go @@ -136,10 +136,7 @@ func BuildCloseEmbed( if reason == nil { formattedReason = "No reason specified" } else { - formattedReason = *reason - if len(formattedReason) > 1024 { - formattedReason = formattedReason[:1024] - } + formattedReason = truncateRunes(*reason, embedFieldValueLimit) } var claimedBy string From 33010ece78544e6a4c3152ef3335be92658b798a Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:13:15 +0100 Subject: [PATCH 12/13] Use panel names in ticket workflows Use configured panel names with title fallbacks across ticket commands, labels, topics, and admin views. Add name-aware autocomplete matching, Unicode-safe string truncation, and tests; remove a duplicate permission condition. --- .../admindebug/server/modals/panelsettings.go | 3 +- .../admindebug/server/modals/permissions.go | 3 +- .../server/modals/ticketpermissions.go | 8 ++-- .../admindebug/server/panelsettings.go | 4 +- .../handlers/admindebug/server/permissions.go | 4 +- .../tickets/edit/labelchangesubmit.go | 3 +- bot/command/impl/tickets/open.go | 5 +-- bot/command/impl/tickets/switchpanel.go | 21 +++++------ bot/logic/open.go | 6 +-- bot/logic/panellabel.go | 20 ++++++++++ bot/logic/panellabel_test.go | 37 +++++++++++++++++++ bot/utils/stringutils.go | 5 ++- bot/utils/stringutils_test.go | 30 +++++++++++++++ 13 files changed, 121 insertions(+), 28 deletions(-) create mode 100644 bot/logic/panellabel.go create mode 100644 bot/logic/panellabel_test.go create mode 100644 bot/utils/stringutils_test.go diff --git a/bot/button/handlers/admindebug/server/modals/panelsettings.go b/bot/button/handlers/admindebug/server/modals/panelsettings.go index fb0d52e5..13597a69 100644 --- a/bot/button/handlers/admindebug/server/modals/panelsettings.go +++ b/bot/button/handlers/admindebug/server/modals/panelsettings.go @@ -18,6 +18,7 @@ import ( "github.com/TicketsBot-cloud/worker/bot/command/context" "github.com/TicketsBot-cloud/worker/bot/customisation" "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/bot/logic" "github.com/TicketsBot-cloud/worker/bot/utils" ) @@ -152,7 +153,7 @@ func buildPanelSettings(ctx *context.ModalContext, worker *w.Context, selectedPa var settings []string // Panel header - settings = append(settings, fmt.Sprintf("**Panel: %s**", selectedPanel.Title)) + settings = append(settings, fmt.Sprintf("**Panel: %s**", logic.PanelLabel(*selectedPanel))) // Basic settings settings = append(settings, fmt.Sprintf("**Message ID:** `%d`", selectedPanel.MessageId)) diff --git a/bot/button/handlers/admindebug/server/modals/permissions.go b/bot/button/handlers/admindebug/server/modals/permissions.go index 1c1ea967..ce6ce229 100644 --- a/bot/button/handlers/admindebug/server/modals/permissions.go +++ b/bot/button/handlers/admindebug/server/modals/permissions.go @@ -21,6 +21,7 @@ import ( "github.com/TicketsBot-cloud/worker/bot/command/context" "github.com/TicketsBot-cloud/worker/bot/customisation" "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/bot/logic" "github.com/TicketsBot-cloud/worker/bot/permissionwrapper" "github.com/TicketsBot-cloud/worker/bot/utils" ) @@ -224,7 +225,7 @@ func processPermissionChecks(selectedValues []string, worker *w.Context, guildId body, hasMissing := checkPanelPermissions(worker, guildId, botMember, *panel) sections = append(sections, permissionSection{ - title: fmt.Sprintf("Panel: %s", panel.Title), + title: fmt.Sprintf("Panel: %s", logic.PanelLabel(*panel)), body: body, hasMissing: hasMissing, }) diff --git a/bot/button/handlers/admindebug/server/modals/ticketpermissions.go b/bot/button/handlers/admindebug/server/modals/ticketpermissions.go index 28ca672f..d56a8113 100644 --- a/bot/button/handlers/admindebug/server/modals/ticketpermissions.go +++ b/bot/button/handlers/admindebug/server/modals/ticketpermissions.go @@ -229,7 +229,7 @@ func checkUserTicketPermissions(ctx *context.ModalContext, worker *w.Context, gu // Check support roles supportRoles, err := dbclient.Client.RolePermissions.GetSupportRoles(ctx, guildId) - if err == nil && ticketPermLevel != "**Ticket Permission:** Admin" && ticketPermLevel != "**Ticket Permission:** Server Owner" && ticketPermLevel != "**Ticket Permission:** Admin" { + if err == nil && ticketPermLevel != "**Ticket Permission:** Admin" && ticketPermLevel != "**Ticket Permission:** Server Owner" { var supportRoleNames []string for _, roleId := range member.Roles { if utils.Contains(supportRoles, roleId) { @@ -282,9 +282,9 @@ func checkUserTicketPermissions(ctx *context.ModalContext, worker *w.Context, gu } if userInTeam { - teamPanelDetails = append(teamPanelDetails, fmt.Sprintf("`%s` (direct)", panel.Title)) + teamPanelDetails = append(teamPanelDetails, fmt.Sprintf("`%s` (direct)", logic.PanelLabel(panel))) } else if len(accessViaRoles) > 0 { - teamPanelDetails = append(teamPanelDetails, fmt.Sprintf("`%s` (via %s)", panel.Title, strings.Join(accessViaRoles, ", "))) + teamPanelDetails = append(teamPanelDetails, fmt.Sprintf("`%s` (via %s)", logic.PanelLabel(panel), strings.Join(accessViaRoles, ", "))) } } @@ -342,7 +342,7 @@ func checkRoleTicketPermissions(ctx *context.ModalContext, worker *w.Context, gu } if utils.Contains(teamRoles, roleId) { - teamPanels = append(teamPanels, fmt.Sprintf("`%s`", panel.Title)) + teamPanels = append(teamPanels, fmt.Sprintf("`%s`", logic.PanelLabel(panel))) } } diff --git a/bot/button/handlers/admindebug/server/panelsettings.go b/bot/button/handlers/admindebug/server/panelsettings.go index 7d02fc3d..625ec776 100644 --- a/bot/button/handlers/admindebug/server/panelsettings.go +++ b/bot/button/handlers/admindebug/server/panelsettings.go @@ -14,6 +14,8 @@ import ( "github.com/TicketsBot-cloud/worker/bot/button/registry/matcher" "github.com/TicketsBot-cloud/worker/bot/command/context" "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/bot/logic" + "github.com/TicketsBot-cloud/worker/bot/utils" ) type AdminDebugServerPanelSettingsHandler struct{} @@ -54,7 +56,7 @@ func (h *AdminDebugServerPanelSettingsHandler) Execute(ctx *context.ButtonContex } panelDesc := fmt.Sprintf("View settings for panel #%d", i+1) options = append(options, component.SelectOption{ - Label: fmt.Sprintf("Panel: %s", panel.Title), + Label: utils.StringMax(fmt.Sprintf("Panel: %s", logic.PanelLabel(panel)), 100), Value: fmt.Sprintf("panel_%d", panel.MessageId), Description: &panelDesc, }) diff --git a/bot/button/handlers/admindebug/server/permissions.go b/bot/button/handlers/admindebug/server/permissions.go index 0edd8a3f..a8d385c1 100644 --- a/bot/button/handlers/admindebug/server/permissions.go +++ b/bot/button/handlers/admindebug/server/permissions.go @@ -14,6 +14,8 @@ import ( "github.com/TicketsBot-cloud/worker/bot/button/registry/matcher" "github.com/TicketsBot-cloud/worker/bot/command/context" "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/bot/logic" + "github.com/TicketsBot-cloud/worker/bot/utils" ) type AdminDebugServerPermissionsHandler struct{} @@ -63,7 +65,7 @@ func (h *AdminDebugServerPermissionsHandler) Execute(ctx *context.ButtonContext) } panelDesc := fmt.Sprintf("Check permissions for panel #%d", i+1) options = append(options, component.SelectOption{ - Label: fmt.Sprintf("Panel: %s", panel.Title), + Label: utils.StringMax(fmt.Sprintf("Panel: %s", logic.PanelLabel(panel)), 100), Value: fmt.Sprintf("panel_%d_%d", panel.MessageId, guildId), Description: &panelDesc, }) diff --git a/bot/button/handlers/tickets/edit/labelchangesubmit.go b/bot/button/handlers/tickets/edit/labelchangesubmit.go index 1acd4b3c..5c5eb8cd 100644 --- a/bot/button/handlers/tickets/edit/labelchangesubmit.go +++ b/bot/button/handlers/tickets/edit/labelchangesubmit.go @@ -15,6 +15,7 @@ import ( commandcontext "github.com/TicketsBot-cloud/worker/bot/command/context" "github.com/TicketsBot-cloud/worker/bot/customisation" "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/bot/logic" "github.com/TicketsBot-cloud/worker/i18n" ) @@ -81,7 +82,7 @@ func (h *LabelChangeSubmitHandler) Execute(ctx *commandcontext.ModalContext) { return } if panel.PanelId != 0 { - topicMsg = fmt.Sprintf("%s | ", panel.Title) + topicMsg = fmt.Sprintf("%s | ", logic.PanelLabel(panel)) } } diff --git a/bot/command/impl/tickets/open.go b/bot/command/impl/tickets/open.go index 55ce4723..0387bda2 100644 --- a/bot/command/impl/tickets/open.go +++ b/bot/command/impl/tickets/open.go @@ -3,7 +3,6 @@ package tickets import ( "context" "errors" - "strings" "time" "github.com/TicketsBot-cloud/common/permission" @@ -84,7 +83,7 @@ func (OpenCommand) AutoCompleteHandler(_ *worker.Context, data interaction.Appli if value != "" { var filtered []database.Panel for _, p := range eligible { - if strings.Contains(strings.ToLower(p.Title), strings.ToLower(value)) { + if logic.PanelMatches(p, value) { filtered = append(filtered, p) } if len(filtered) == 25 { @@ -101,7 +100,7 @@ func (OpenCommand) AutoCompleteHandler(_ *worker.Context, data interaction.Appli choices := make([]interaction.ApplicationCommandOptionChoice, len(eligible)) for i, p := range eligible { choices[i] = interaction.ApplicationCommandOptionChoice{ - Name: p.Title, + Name: logic.PanelLabel(p), Value: p.CustomId, } } diff --git a/bot/command/impl/tickets/switchpanel.go b/bot/command/impl/tickets/switchpanel.go index ce8f2575..d7455bde 100644 --- a/bot/command/impl/tickets/switchpanel.go +++ b/bot/command/impl/tickets/switchpanel.go @@ -3,7 +3,6 @@ package tickets import ( "context" "fmt" - "strings" "time" "github.com/TicketsBot-cloud/common/permission" @@ -224,9 +223,9 @@ func (SwitchPanelCommand) Execute(ctx *cmdcontext.SlashCommandContext, panelId i } member, err := ctx.Member() - auditReason := fmt.Sprintf("Switched ticket %d to panel '%s'", ticket.Id, newPanel.Title) + auditReason := fmt.Sprintf("Switched ticket %d to panel '%s'", ticket.Id, logic.PanelLabel(newPanel)) if err == nil { - auditReason = fmt.Sprintf("Switched ticket %d to panel '%s' by %s", ticket.Id, newPanel.Title, member.User.Username) + auditReason = fmt.Sprintf("Switched ticket %d to panel '%s' by %s", ticket.Id, logic.PanelLabel(newPanel), member.User.Username) } reasonCtx := request.WithAuditReason(ctx, auditReason) @@ -235,7 +234,7 @@ func (SwitchPanelCommand) Execute(ctx *cmdcontext.SlashCommandContext, panelId i return } - ctx.ReplyRaw(customisation.Green, "Success", fmt.Sprintf("This ticket has been switched to the panel **%s**.\n\nNote: As this is a thread, the permissions could not be bulk updated.", newPanel.Title)) + ctx.ReplyRaw(customisation.Green, "Success", fmt.Sprintf("This ticket has been switched to the panel **%s**.\n\nNote: As this is a thread, the permissions could not be bulk updated.", logic.PanelLabel(newPanel))) // Modify join message if ticket.JoinMessageId != nil { @@ -304,16 +303,16 @@ func (SwitchPanelCommand) Execute(ctx *cmdcontext.SlashCommandContext, panelId i data := rest.ModifyChannelData{ PermissionOverwrites: overwrites, ParentId: newPanel.TargetCategory, - Topic: newPanel.Title, + Topic: logic.PanelLabel(newPanel), } if shouldUpdateName { data.Name = newChannelName } member, err := ctx.Member() - auditReason := fmt.Sprintf("Switched ticket %d to panel '%s'", ticket.Id, newPanel.Title) + auditReason := fmt.Sprintf("Switched ticket %d to panel '%s'", ticket.Id, logic.PanelLabel(newPanel)) if err == nil { - auditReason = fmt.Sprintf("Switched ticket %d to panel '%s' by %s", ticket.Id, newPanel.Title, member.User.Username) + auditReason = fmt.Sprintf("Switched ticket %d to panel '%s' by %s", ticket.Id, logic.PanelLabel(newPanel), member.User.Username) } reasonCtx := request.WithAuditReason(ctx, auditReason) @@ -327,9 +326,9 @@ func (SwitchPanelCommand) Execute(ctx *cmdcontext.SlashCommandContext, panelId i if err := logic.UpdateWelcomeMessageClaimButton(ctx.Context, ctx.Worker(), ctx, ticket, false); err != nil { ctx.HandleWarning(err) } - ctx.ReplyPermanent(customisation.Green, i18n.TitlePanelSwitched, i18n.MessageSwitchPanelAutoUnclaimed, newPanel.Title, ctx.UserId(), originalClaimer) + ctx.ReplyPermanent(customisation.Green, i18n.TitlePanelSwitched, i18n.MessageSwitchPanelAutoUnclaimed, logic.PanelLabel(newPanel), ctx.UserId(), originalClaimer) } else { - ctx.ReplyPermanent(customisation.Green, i18n.TitlePanelSwitched, i18n.MessageSwitchPanelSuccess, newPanel.Title, ctx.UserId()) + ctx.ReplyPermanent(customisation.Green, i18n.TitlePanelSwitched, i18n.MessageSwitchPanelSuccess, logic.PanelLabel(newPanel), ctx.UserId()) } } @@ -356,7 +355,7 @@ func (SwitchPanelCommand) AutoCompleteHandler(_ *worker.Context, data interactio } else { var filtered []database.Panel for _, panel := range panels { - if strings.Contains(strings.ToLower(panel.Title), strings.ToLower(value)) { + if logic.PanelMatches(panel, value) { filtered = append(filtered, panel) } @@ -373,7 +372,7 @@ func panelsToChoices(panels []database.Panel) []interaction.ApplicationCommandOp choices := make([]interaction.ApplicationCommandOptionChoice, len(panels)) for i, panel := range panels { choices[i] = interaction.ApplicationCommandOptionChoice{ - Name: panel.Title, + Name: logic.PanelLabel(panel), Value: panel.PanelId, } } diff --git a/bot/logic/open.go b/bot/logic/open.go index 8d857c0e..7c4be648 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -206,8 +206,8 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat } // Generate subject - if panel != nil && panel.Title != "" { // If we're using a panel, use the panel title as the subject - subject = panel.Title + if panel != nil && PanelLabel(*panel) != "" { + subject = PanelLabel(*panel) } else { // Else, take command args as the subject if subject == "" { subject = "No subject given" @@ -1473,7 +1473,7 @@ func buildJoinThreadMessage( panelName := "None" if panel != nil { - panelName = panel.Title + panelName = PanelLabel(*panel) } title := "Join Ticket" diff --git a/bot/logic/panellabel.go b/bot/logic/panellabel.go new file mode 100644 index 00000000..f59ee74d --- /dev/null +++ b/bot/logic/panellabel.go @@ -0,0 +1,20 @@ +package logic + +import ( + "strings" + + "github.com/TicketsBot-cloud/database" +) + +func PanelLabel(panel database.Panel) string { + if panel.Name != nil && *panel.Name != "" { + return *panel.Name + } + + return panel.Title +} + +func PanelMatches(panel database.Panel, query string) bool { + query = strings.ToLower(query) + return strings.Contains(strings.ToLower(PanelLabel(panel)), query) || strings.Contains(strings.ToLower(panel.Title), query) +} diff --git a/bot/logic/panellabel_test.go b/bot/logic/panellabel_test.go new file mode 100644 index 00000000..0d048fbd --- /dev/null +++ b/bot/logic/panellabel_test.go @@ -0,0 +1,37 @@ +package logic + +import ( + "testing" + + "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/worker/bot/utils" + "github.com/stretchr/testify/require" +) + +func TestPanelLabel(t *testing.T) { + tests := []struct { + name string + in *string + want string + }{ + {"no name", nil, "Open a ticket"}, + {"empty name", utils.Ptr(""), "Open a ticket"}, + {"name", utils.Ptr("Billing support"), "Billing support"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, PanelLabel(database.Panel{Title: "Open a ticket", Name: tt.in})) + }) + } +} + +func TestPanelMatches(t *testing.T) { + panel := database.Panel{Title: "Open a ticket", Name: utils.Ptr("Billing support")} + + require.True(t, PanelMatches(panel, "billing")) + require.True(t, PanelMatches(panel, "OPEN A")) + require.True(t, PanelMatches(panel, "")) + require.False(t, PanelMatches(panel, "refund")) + require.True(t, PanelMatches(database.Panel{Title: "Open a ticket"}, "ticket")) +} diff --git a/bot/utils/stringutils.go b/bot/utils/stringutils.go index 52991601..1c314b10 100644 --- a/bot/utils/stringutils.go +++ b/bot/utils/stringutils.go @@ -4,6 +4,7 @@ import ( "golang.org/x/exp/constraints" "math/rand" "strings" + "unicode/utf8" ) type Number interface { @@ -11,8 +12,8 @@ type Number interface { } func StringMax(str string, max int, suffix ...string) string { - if len(str) > max { - return str[:max] + strings.Join(suffix, "") + if utf8.RuneCountInString(str) > max { + return string([]rune(str)[:max]) + strings.Join(suffix, "") } return str diff --git a/bot/utils/stringutils_test.go b/bot/utils/stringutils_test.go new file mode 100644 index 00000000..005271a0 --- /dev/null +++ b/bot/utils/stringutils_test.go @@ -0,0 +1,30 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStringMax(t *testing.T) { + tests := []struct { + name string + in string + max int + suffix []string + want string + }{ + {"fits", "hello", 5, nil, "hello"}, + {"cut", "hello", 3, nil, "hel"}, + {"suffix only when cut", "hello", 3, []string{"..."}, "hel..."}, + {"multibyte counts characters", "ζ—₯本θͺžγƒ†γ‚­γ‚Ήγƒˆ", 3, nil, "ζ—₯本θͺž"}, + {"multibyte that fits is kept", "ζ—₯本θͺž", 3, nil, "ζ—₯本θͺž"}, + {"emoji is never split", "πŸ˜€πŸ˜€πŸ˜€", 2, nil, "πŸ˜€πŸ˜€"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, StringMax(tt.in, tt.max, tt.suffix...)) + }) + } +} From 3a45bd3f9fb988acf7b5c80fd81a54ff78e1e2e8 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:56:39 +0100 Subject: [PATCH 13/13] Handle embed limits and grapheme-safe splits Trim oversized form responses, preserve Markdown block boundaries and grapheme clusters when splitting answers, and truncate ticket closure reasons safely. Add coverage for the new limit handling and promote uniseg to a direct dependency. --- bot/command/impl/tickets/switchpanel.go | 1 + bot/logic/close.go | 4 +- bot/logic/welcomemessage_limits.go | 80 ++++++++++++++++++++++--- bot/logic/welcomemessage_limits_test.go | 68 +++++++++++++++++++++ go.mod | 2 +- 5 files changed, 144 insertions(+), 11 deletions(-) diff --git a/bot/command/impl/tickets/switchpanel.go b/bot/command/impl/tickets/switchpanel.go index d7455bde..2330dc48 100644 --- a/bot/command/impl/tickets/switchpanel.go +++ b/bot/command/impl/tickets/switchpanel.go @@ -197,6 +197,7 @@ func (SwitchPanelCommand) Execute(ctx *cmdcontext.SlashCommandContext, panelId i return } + embeds = logic.TrimFormAnswers(embeds) for i := 1; i < len(embeds); i++ { embeds[i].SetColorPtr(embeds[0].Color) } diff --git a/bot/logic/close.go b/bot/logic/close.go index 1203d00e..a7c16e4b 100644 --- a/bot/logic/close.go +++ b/bot/logic/close.go @@ -211,7 +211,7 @@ func CloseTicket(ctx context.Context, cmd registry.CommandContext, reason *strin fields = []embed.EmbedField{ { Name: cmd.GetMessage(i18n.Reason), - Value: fmt.Sprintf("```%s```", *reason), + Value: fmt.Sprintf("```%s```", truncateRunes(*reason, embedFieldValueLimit-6)), Inline: false, }, } @@ -230,7 +230,7 @@ func CloseTicket(ctx context.Context, cmd registry.CommandContext, reason *strin fields := []embed.EmbedField{ { Name: cmd.GetMessage(i18n.Reason), - Value: fmt.Sprintf("```%s```", *reason), + Value: fmt.Sprintf("```%s```", truncateRunes(*reason, embedFieldValueLimit-6)), Inline: false, }, } diff --git a/bot/logic/welcomemessage_limits.go b/bot/logic/welcomemessage_limits.go index 4b2c2590..0ac50dde 100644 --- a/bot/logic/welcomemessage_limits.go +++ b/bot/logic/welcomemessage_limits.go @@ -1,6 +1,7 @@ package logic import ( + "regexp" "sort" "strings" "unicode" @@ -9,6 +10,7 @@ import ( "github.com/TicketsBot-cloud/gdl/objects/channel/embed" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/worker/bot/utils" + "github.com/rivo/uniseg" ) const ( @@ -228,7 +230,7 @@ func splitAnswer(value string) []string { var chunks []string for len(runes) > embedFieldValueLimit { - cut := answerCut(runes[:embedFieldValueLimit+1]) + cut := answerCut(runes) if chunk := strings.TrimRightFunc(string(runes[:cut]), unicode.IsSpace); chunk != "" { chunks = append(chunks, chunk) } @@ -244,23 +246,52 @@ func splitAnswer(value string) []string { } // Continue at the most natural break, but not before half a field, so an answer doesn't become many small blocks. -func answerCut(window []rune) int { - limit := len(window) - 1 +func answerCut(runes []rune) int { + window := runes[:embedFieldValueLimit+1] + limit := embedFieldValueLimit for _, isBreak := range []func(r []rune, i int) bool{isParagraphBreak, isLineBreak, isSentenceEnd, isWordBreak} { for i := limit; i >= minAnswerChunk; i-- { - if isBreak(window, i) { + // Text from mid-line would start a list or quote at the top of the next field. + if isBreak(window, i) && (window[i] == '\n' || !startsBlock(runes[i:])) { return i } } } // An escaping backslash stays with the character it escapes, or the next block would start unescaped. - cut := limit - if trailingBackslashes(window[:cut])%2 == 1 { - cut-- + starts := graphemeStarts(window) + for j := len(starts) - 1; j >= 0 && starts[j] >= minAnswerChunk; j-- { + if cut := starts[j]; trailingBackslashes(window[:cut])%2 == 0 && !startsBlock(runes[cut:]) { + return cut + } + } + + return limit - trailingBackslashes(window[:limit])%2 +} + +var blockMarker = regexp.MustCompile(`^(?:>>> |> |[-*] |\d+\. )`) + +func startsBlock(rest []rune) bool { + i := 0 + for i < len(rest) && unicode.IsSpace(rest[i]) { + i++ + } + + return blockMarker.MatchString(string(rest[i:min(len(rest), i+16)])) +} + +func graphemeStarts(r []rune) []int { + var starts []int + s, state, pos := string(r), -1, 0 + for s != "" { + starts = append(starts, pos) + + var cluster string + cluster, s, _, state = uniseg.FirstGraphemeClusterInString(s, state) + pos += utf8.RuneCountInString(cluster) } - return cut + return starts } func isParagraphBreak(r []rune, i int) bool { return r[i] == '\n' && r[i-1] == '\n' } @@ -286,6 +317,39 @@ func AddFormAnswerFields(e *embed.Embed, fields []embed.EmbedField) { } } +func TrimFormAnswers(embeds []*embed.Embed) []*embed.Embed { + over := -embedTotalLimit + for _, e := range embeds { + over += embedTextLength(e) + } + + for len(embeds) > 1 && over > 0 { + e := embeds[len(embeds)-1] + if len(e.Fields) == 0 { + over -= embedTextLength(e) + embeds = embeds[:len(embeds)-1] + continue + } + + last := e.Fields[len(e.Fields)-1] + n := utf8.RuneCountInString(last.Value) + if n-over > 1 { + last.Value = truncateWithEllipsis(last.Value, n-over) + over -= n - utf8.RuneCountInString(last.Value) + continue + } + + over -= utf8.RuneCountInString(last.Name) + n + e.Fields = e.Fields[:len(e.Fields)-1] + if last.Name == continuationFieldName { + // Cut into the part before, so the answer still ends with an ellipsis. + over = max(over, 1) + } + } + + return embeds +} + func embedTextLength(e *embed.Embed) int { if e == nil { return 0 diff --git a/bot/logic/welcomemessage_limits_test.go b/bot/logic/welcomemessage_limits_test.go index 779c43ff..2dc6905c 100644 --- a/bot/logic/welcomemessage_limits_test.go +++ b/bot/logic/welcomemessage_limits_test.go @@ -599,6 +599,12 @@ func TestSplitAnswer(t *testing.T) { {"escape stays together", a(1023) + `\*` + a(100), []string{a(1023), `\*` + a(100)}}, {"escaped backslash splits normally", a(1022) + `\\` + a(100), []string{a(1022) + `\\`, a(100)}}, {"blank block dropped", strings.Repeat(" ", 1100) + "x", []string{"x"}}, + {"sentence end before a dash", a(600) + ". - " + a(600), []string{a(600) + ". -", a(600)}}, + {"sentence end before a quote", a(600) + ". > " + a(600), []string{a(600) + ". >", a(600)}}, + {"word break before a number", words("ab", 340) + " xyz 2. " + a(100), []string{words("ab", 340), "xyz 2. " + a(100)}}, + {"word break before a dash", a(1023) + " - " + a(100), []string{a(1022), "a - " + a(100)}}, + {"flags stay whole", "x" + strings.Repeat("πŸ‡©πŸ‡°", 600), []string{"x" + strings.Repeat("πŸ‡©πŸ‡°", 511), strings.Repeat("πŸ‡©πŸ‡°", 89)}}, + {"emoji sequences stay whole", strings.Repeat("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", 200), []string{strings.Repeat("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", 146), strings.Repeat("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", 54)}}, } for _, tt := range tests { @@ -711,6 +717,68 @@ func TestAddFormAnswerFields(t *testing.T) { } } +func TestTrimFormAnswers(t *testing.T) { + posted := func(welcomeLen int, values ...string) []*embed.Embed { + welcome := embed.NewEmbed().SetDescription(strings.Repeat("w", welcomeLen)) + answers := embed.NewEmbed().SetFooter("Powered by tickets.bot", "") + for _, f := range fitClassicFormAnswers(answerFields(values...), embedTextLength(welcome)+embedTextLength(answers)) { + answers.AddField(f.Name, f.Value, f.Inline) + } + + return []*embed.Embed{welcome, answers} + } + + long := strings.Repeat("a", 3000) + + tests := []struct { + name string + embeds []*embed.Embed + welcomeLen int + wantFields int + }{ + {"fits unchanged", posted(145, "short"), 409, 1}, + {"longer welcome cuts the last part", posted(145, long, long), 409, 6}, + {"dropped part leaves an ellipsis", posted(145, long, long), 1012, 5}, + {"whole parts dropped", posted(145, long, long), 3000, 4}, + {"answers dropped when nothing fits", posted(145, long), 5990, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + before := make([]string, len(tt.embeds[1].Fields)) + for i, f := range tt.embeds[1].Fields { + before[i] = f.Value + } + + tt.embeds[0] = embed.NewEmbed().SetDescription(strings.Repeat("w", tt.welcomeLen)) + got := TrimFormAnswers(tt.embeds) + + total := 0 + for _, e := range got { + total += embedTextLength(e) + } + require.LessOrEqual(t, total, embedTotalLimit) + + if tt.wantFields == 0 { + require.Len(t, got, 1) + return + } + + require.Len(t, got, 2) + fields := got[1].Fields + require.Len(t, fields, tt.wantFields) + for i, f := range fields[:len(fields)-1] { + require.Equal(t, before[i], f.Value) + } + + last := fields[len(fields)-1] + if len(fields) < len(before) || last.Value != before[len(fields)-1] { + require.True(t, strings.HasSuffix(last.Value, truncationEllipsis)) + } + }) + } +} + func TestGetFormDataFieldsBlankAnswers(t *testing.T) { tests := []struct { name string diff --git a/go.mod b/go.mod index 1775a631..ba7c2e2a 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/json-iterator/go v1.1.12 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.22.0 + github.com/rivo/uniseg v0.4.7 github.com/schollz/progressbar/v3 v3.18.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 @@ -112,7 +113,6 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.63.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/xid v1.6.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect