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/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 59a5912e..0ff7a777 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -2,6 +2,7 @@ package logic import ( "context" + "encoding/json" "errors" "fmt" "regexp" @@ -10,12 +11,14 @@ import ( "strings" "sync" "time" + "unicode/utf8" "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" @@ -44,6 +47,15 @@ func SendWelcomeMessage( // Only custom integration placeholders for now - prevent making duplicate requests 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) + 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 welcomeMessageEmbed, err := BuildWelcomeMessageEmbed(ctx, cmd, ticket, subject, panel, additionalPlaceholders) if err != nil { @@ -58,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 @@ -126,6 +142,226 @@ func SendWelcomeMessage( return msg.Id, nil } +// 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 +} + +func sendWelcomeMessageV2( + ctx context.Context, + 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, +) (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) + }, + Label: func(s string) string { + return truncateRunes(DoPlainTextPlaceholderSubstitutions(ctx, s, cmd.Worker(), ticket, additionalPlaceholders), buttonLabelLimit) + }, + Url: func(s string) string { + return replaceAvatarPlaceholder(cmd.Worker(), ticket, s) + }, + }) + + 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) + + // 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, + } + + msg, err := cmd.Worker().CreateMessageComplex(*ticket.ChannelId, data) + if err != nil { + return 0, err + } + + 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 { + 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 i, answer := range answers { + out = append(out, component.BuildTextDisplay(component.TextDisplay{ + Content: headers[i] + answer, + })) + } + + if len(buttons) > 0 { + out = append(out, component.BuildActionRow(buttons...)) + } + + 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 + } + + out := make([]component.Component, len(components)) + for i, c := range components { + out[i] = transformComponentTextOne(c, t) + } + + return out +} + +func transformComponentTextOne(c component.Component, t componentTextTransform) component.Component { + switch data := c.ComponentData.(type) { + case component.TextDisplay: + data.Content = expandOrKeepRaw(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 = expandOrKeepRaw(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 c +} + func BuildWelcomeMessageEmbed( ctx context.Context, cmd registry.CommandContext, @@ -725,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/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 e8f33ccf..33689937 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= diff --git a/restwrapper.go b/restwrapper.go index 53e71c6c..8558ccb9 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) }