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 138430f5..c460251b 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 c7162815..57e986ab 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/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/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/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/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/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/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/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/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 5ffb55c3..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" @@ -68,7 +67,7 @@ func (OpenCommand) AutoCompleteHandler(_ *worker.Context, data interaction.Appli 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 @@ -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 6f2ad805..2330dc48 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" @@ -181,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(ctx.Context, ctx, ticket, msg, &newPanel) { var subject string embeds := utils.PtrElems(msg.Embeds) // TODO: Fix types @@ -198,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) } @@ -224,9 +224,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 +235,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 +304,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 +327,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()) } } @@ -341,7 +341,7 @@ func (SwitchPanelCommand) AutoCompleteHandler(_ *worker.Context, data interactio 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 @@ -356,7 +356,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 +373,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/command/messageresponse.go b/bot/command/messageresponse.go index 0010ecd0..93e9c453 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, } + + if r.isComponentsV2() { + data.Embeds = []*embed.Embed{} + data.AllowedMentions = &r.AllowedMentions + } 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..f861b2d7 100644 --- a/bot/command/messageresponse_test.go +++ b/bot/command/messageresponse_test.go @@ -71,3 +71,41 @@ 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) + 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 ebd04a53..113c9989 100644 --- a/bot/logic/claim.go +++ b/bot/logic/claim.go @@ -9,6 +9,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" @@ -693,68 +694,77 @@ 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 +} + +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 +} + +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 +} + +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/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/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 diff --git a/bot/logic/open.go b/bot/logic/open.go index ad68b28c..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" @@ -466,7 +466,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 } @@ -1469,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/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 4820bc3b..70eacf74 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,15 +11,18 @@ 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" + "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" @@ -44,6 +48,20 @@ 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, 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)) + } + // Build embeds welcomeMessageEmbed, err := BuildWelcomeMessageEmbed(ctx, cmd, ticket, subject, panel, additionalPlaceholders) if err != nil { @@ -58,17 +76,47 @@ func SendWelcomeMessage( formAnswersEmbed := embed.NewEmbed(). SetColorPtr(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) + } + } + + 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 @@ -94,7 +142,7 @@ func SendWelcomeMessage( })) } - if !hideClaim && !ticket.IsThread { + if !hideClaim && !isThread { buttons = append(buttons, component.BuildButton(component.Button{ Label: cmd.GetMessage(i18n.TitleClaim), CustomId: "claim", @@ -103,27 +151,219 @@ func SendWelcomeMessage( })) } + return buttons +} + +func welcomeV2Tree(panel *database.Panel, paidPremium bool) ([]component.Component, error) { + if panel == nil || !panel.WelcomeMessageUsesComponentsV2 || !paidPremium { + return nil, nil + } + + 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 +} + +func hasPaidPremium(tier premium.PremiumTier, source model.EntitlementSource) bool { + return tier != premium.None && source != model.EntitlementSourceVoting +} + +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, + additionalPlaceholders map[string]string, +) (msgId uint64, rejected bool, err error) { + if ticket.ChannelId == nil { + return 0, true, fmt.Errorf("channel is nil") + } + + 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 withDefaultAvatar(s, replaceAvatarPlaceholder(cmd.Worker(), ticket, s), ticket.UserId) + }, + }) + + finalTree := buildWelcomeV2Components(tree, getFormDataFields(formData), welcomeButtons(cmd, panel, ticket.IsThread)) + data := rest.CreateMessageData{ - Embeds: embeds, + 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 msg.Id, false, 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 { + headers := make([]string, len(fields)) + needs := make([]int, len(fields)) + reserve := 0 + for i, field := range fields { + headers[i] = v2AnswerHeader(field) + needs[i] = utf8.RuneCountInString(headers[i]) + utf8.RuneCountInString(utils.EscapeMarkdown(field.Value)) + reserve += min(needs[i], v2AnswerFloor) + } + + tree, used := fitTemplateText(tree, v2TextDisplayBudget-reserve) + allowances := waterFill(needs, v2TextDisplayBudget-used) + + out := make([]component.Component, 0, len(tree)+2) + out = append(out, tree...) + + 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{ + AccentColor: lastContainerAccent(tree), + Components: answers, + })) } if len(buttons) > 0 { - data.Components = []component.Component{ - component.BuildActionRow(buttons...), + out = append(out, component.BuildActionRow(buttons...)) + } + + 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 } } - // Should never happen - if ticket.ChannelId == nil { - return 0, fmt.Errorf("channel is nil") + return nil +} + +type componentTextTransform struct { + Text func(string) string + Label func(string) string + Url func(string) string +} + +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 +} + +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( @@ -200,6 +440,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 @@ -216,7 +459,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() @@ -725,7 +968,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? } @@ -895,3 +1138,11 @@ func replaceAvatarPlaceholder(worker *worker.Context, ticket database.Ticket, ur return user.AvatarUrl(256) } + +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 new file mode 100644 index 00000000..0ac50dde --- /dev/null +++ b/bot/logic/welcomemessage_limits.go @@ -0,0 +1,382 @@ +package logic + +import ( + "regexp" + "sort" + "strings" + "unicode" + "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" + "github.com/rivo/uniseg" +) + +const ( + v2TextDisplayBudget = 4000 + v2AnswerFloor = 200 + buttonLabelLimit = 80 + + 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 = "โ€ฆ" +) + +// 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 +} + +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 +} + +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 +} + +func fitAnswerValues(values []string, overheads []int, budget 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])) + 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 +} + +func fitTemplateText(tree []component.Component, budget int) ([]component.Component, int) { + contents := textDisplayContents(tree) + + lengths := make([]int, len(contents)) + total := 0 + 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 { + fitted[i] = truncateWithEllipsis(c, max(allowances[i], 1)) + 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 +} + +// EscapeMarkdown leaves backslashes alone, and a trailing one would escape the closing **. +func v2AnswerHeader(field embed.EmbedField) string { + return "**" + utils.EscapeMarkdown(strings.ReplaceAll(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]) + } + + // 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 + } + + 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) + 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(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-- { + // 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. + 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 starts +} + +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) + } +} + +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 + } + + 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 +} + +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..2dc6905c --- /dev/null +++ b/bot/logic/welcomemessage_limits_test.go @@ -0,0 +1,800 @@ +package logic + +import ( + "math/rand" + "slices" + "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/TicketsBot-cloud/worker/bot/utils" + "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 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) + + tests := []struct { + name string + template []component.Component + answers []string + wantAnswers int + wantTruncated bool + wantTemplateCut 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, + wantTemplateCut: 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: "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))}, + 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, + wantTemplateCut: true, + }, + { + 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", + 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: 1, + wantTemplateCut: true, + }, + { + 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)) + } + + templateCut := !slices.Equal(textDisplayContents(tt.template), textDisplayContents(got[:len(tt.template)])) + require.Equal(t, tt.wantTemplateCut, templateCut) + + answers := answerDisplays(t, got, len(tt.template)) + require.Len(t, answers, tt.wantAnswers) + + truncated := false + for _, c := range answers { + 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", 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) + + 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)) +} + +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) + + 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)) +} + +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) + + answers := answerDisplays(t, got, 1) + require.Len(t, answers, 5) + require.LessOrEqual(t, totalTextDisplayLength(got), v2TextDisplayBudget) + for _, c := range answers { + 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", answerDisplays(t, got, 0)[0].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 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%"), + 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 continues", + fields: []embed.EmbedField{{Name: "Q", Value: strings.Repeat("a", embedFieldValueLimit+1)}}, + wantCount: 2, + check: func(t *testing.T, got []embed.EmbedField) { + 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: 2, + check: func(t *testing.T, got []embed.EmbedField) { + 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: 2, + check: func(t *testing.T, got []embed.EmbedField) { + require.Equal(t, embedFieldValueLimit, runeLen(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)) + }, + }, + { + 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", + 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: "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, + 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 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"}}, + {"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 { + 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) + + require.Equal(t, 2+2+4+2+2, embedTextLength(e)) + require.Equal(t, 0, embedTextLength(nil)) +} + +func TestAddFormAnswerFields(t *testing.T) { + long := strings.Repeat("a", 3000) + + tests := []struct { + name string + values []string + wantFields int + }{ + {"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 { + 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, tt.wantFields) + for _, f := range e.Fields { + require.LessOrEqual(t, runeLen(f.Value), embedFieldValueLimit) + } + + require.LessOrEqual(t, embedTextLength(e), embedTotalLimit) + require.Contains(t, e.Fields[len(e.Fields)-1].Value, "short") + }) + } +} + +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 + 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..5c32eb4f --- /dev/null +++ b/bot/logic/welcomemessage_v2_test.go @@ -0,0 +1,774 @@ +package logic + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "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/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" +) + +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) + + 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"})} + + 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, 3}, + {"over the old budget", 12, buttons, 3}, + {"no buttons", 12, nil, 2}, + } + + 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 { + 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) + } + }) + } +} + +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 { + 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"}), + 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", + 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 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()}}, + 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) + } + }) + } +} + +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, WelcomeMessageComponents: strPtr(`[{"type":10,"content":"hello"}]`)} + + tests := []struct { + 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 + }{ + {"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) { + 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) + }) + } +} + +type capturedRequest struct { + method string + body map[string]any +} + +type captureTransport struct { + response string + statuses []int + requests []capturedRequest +} + +const transportFailure = -1 + +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) + + if status == transportFailure { + return nil, errors.New("timeout awaiting response headers") + } + + 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 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"}`) + + 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, 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) + 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.Equal(t, "w7", body["nonce"]) + require.Equal(t, true, body["enforce_nonce"]) + 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%", containerTexts(t, components[1])[0], "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, []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") + require.Len(t, posts[1]["embeds"], 2) + 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"} + + 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 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 + `"}]}` + } + 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])) + }) + } +} 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...)) + }) + } +} diff --git a/go.mod b/go.mod index f91b3e11..ba7c2e2a 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 @@ -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 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) }