diff --git a/bot/button/handlers/addadmin.go b/bot/button/handlers/addadmin.go index 1d283ee4..c493dcd9 100644 --- a/bot/button/handlers/addadmin.go +++ b/bot/button/handlers/addadmin.go @@ -159,7 +159,7 @@ func (h *AddAdminHandler) Execute(ctx *context.ButtonContext) { auditReason = fmt.Sprintf("Added admin member/role by %s", member.User.Username) } - panels, _ := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) + panels, _ := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, ctx.GuildId()) seen := make(map[uint64]struct{}) for _, p := range panels { if p.TicketNotificationChannel == nil { diff --git a/bot/button/handlers/addsupport.go b/bot/button/handlers/addsupport.go index ab90e3de..c08805f8 100644 --- a/bot/button/handlers/addsupport.go +++ b/bot/button/handlers/addsupport.go @@ -157,7 +157,7 @@ func updateChannelPermissions(ctx cmdregistry.CommandContext, id uint64, mention return } - panels, err := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) + panels, err := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, ctx.GuildId()) if err != nil { ctx.HandleError(err) return diff --git a/bot/command/context/mentionabletype.go b/bot/command/context/mentionabletype.go index 0b6557f0..880a8144 100644 --- a/bot/command/context/mentionabletype.go +++ b/bot/command/context/mentionabletype.go @@ -13,6 +13,14 @@ const ( MentionableTypeRole ) +func (m MentionableType) String() string { + if m == MentionableTypeRole { + return "role" + } + + return "user" +} + func (m MentionableType) OverwriteType() channel.PermissionOverwriteType { switch m { case MentionableTypeUser: diff --git a/bot/command/impl/settings/removeadmin.go b/bot/command/impl/settings/removeadmin.go index 0945149b..1d242797 100644 --- a/bot/command/impl/settings/removeadmin.go +++ b/bot/command/impl/settings/removeadmin.go @@ -134,7 +134,7 @@ func (c RemoveAdminCommand) Execute(ctx registry.CommandContext, id uint64) { auditReason = fmt.Sprintf("Removed admin member/role by %s", member.User.Username) } - panels, _ := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) + panels, _ := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, ctx.GuildId()) seen := make(map[uint64]struct{}) for _, p := range panels { if p.TicketNotificationChannel == nil { diff --git a/bot/command/impl/settings/removesupport.go b/bot/command/impl/settings/removesupport.go index ac007795..e089a746 100644 --- a/bot/command/impl/settings/removesupport.go +++ b/bot/command/impl/settings/removesupport.go @@ -145,7 +145,7 @@ func (c RemoveSupportCommand) Execute(ctx registry.CommandContext, id uint64) { auditReason = fmt.Sprintf("Removed support member/role by %s", member.User.Username) } - panels, _ := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) + panels, _ := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, ctx.GuildId()) seen := make(map[uint64]struct{}) for _, p := range panels { if p.TicketNotificationChannel == nil { diff --git a/bot/command/impl/tickets/oncall.go b/bot/command/impl/tickets/oncall.go index 3f7ca3f7..013b5d2e 100644 --- a/bot/command/impl/tickets/oncall.go +++ b/bot/command/impl/tickets/oncall.go @@ -39,7 +39,7 @@ func (c OnCallCommand) GetExecutor() interface{} { } func (OnCallCommand) Execute(ctx registry.CommandContext) { - panels, err := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) + panels, err := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, ctx.GuildId()) if err != nil { ctx.HandleError(err) return diff --git a/bot/command/impl/tickets/open.go b/bot/command/impl/tickets/open.go index 3601c6bb..19f113eb 100644 --- a/bot/command/impl/tickets/open.go +++ b/bot/command/impl/tickets/open.go @@ -67,7 +67,7 @@ func (OpenCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCo ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) defer cancel() - allPanels, err := dbclient.Client.Panel.GetByGuild(ctx, data.GuildId.Value) + allPanels, err := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, data.GuildId.Value) if err != nil { sentry.Error(err) return nil diff --git a/bot/command/impl/tickets/switchpanel.go b/bot/command/impl/tickets/switchpanel.go index ca043f79..c02d02d1 100644 --- a/bot/command/impl/tickets/switchpanel.go +++ b/bot/command/impl/tickets/switchpanel.go @@ -180,7 +180,7 @@ func (SwitchPanelCommand) Execute(ctx *cmdcontext.SlashCommandContext, panelId i msg, err := ctx.Worker().GetChannelMessage(*ticket.ChannelId, *ticket.WelcomeMessageId) // Error is likely to be due to message being deleted, we want to continue further even if it is - if err == nil { + if err == nil && logic.ShouldRewriteClassicWelcome(msg, &newPanel) { var subject string embeds := utils.PtrElems(msg.Embeds) // TODO: Fix types @@ -340,7 +340,7 @@ func (SwitchPanelCommand) AutoCompleteHandler(data interaction.ApplicationComman ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) // TODO: Propagate context defer cancel() - panels, err := dbclient.Client.Panel.GetByGuild(ctx, data.GuildId.Value) + panels, err := dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, data.GuildId.Value) if err != nil { sentry.Error(err) // TODO: Context return nil diff --git a/bot/command/messageresponse.go b/bot/command/messageresponse.go index 0010ecd0..815d532e 100644 --- a/bot/command/messageresponse.go +++ b/bot/command/messageresponse.go @@ -95,12 +95,20 @@ func (r *MessageResponse) IntoCreateMessageData() rest.CreateMessageData { } func (r *MessageResponse) IntoEditMessageData() rest.EditMessageData { - return rest.EditMessageData{ - Content: r.Content, - Embeds: r.Embeds, + data := rest.EditMessageData{ Flags: r.Flags, Components: r.Components, } + + // V2 messages cannot carry content, and Discord requires embeds to be an empty array. + if r.isComponentsV2() { + data.Embeds = []*embed.Embed{} + } else { + data.Content = r.Content + data.Embeds = r.Embeds + } + + return data } func (r *MessageResponse) IntoWebhookBody() rest.WebhookBody { diff --git a/bot/command/messageresponse_test.go b/bot/command/messageresponse_test.go index 9079e282..47423866 100644 --- a/bot/command/messageresponse_test.go +++ b/bot/command/messageresponse_test.go @@ -71,3 +71,39 @@ func TestMessageResponseEditsOmitContentAndEmbedsUnderComponentsV2(t *testing.T) }) } } + +func TestMessageResponseIntoEditMessageData(t *testing.T) { + components := []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "hello"})} + embeds := []*embed.Embed{embed.NewEmbed().SetTitle("legacy")} + + tests := []struct { + name string + flags uint + wantLegacy bool + }{ + {"components v2", message.SumFlags(message.FlagComponentsV2), false}, + {"ephemeral components v2", message.SumFlags(message.FlagEphemeral, message.FlagComponentsV2), false}, + {"legacy", 0, true}, + {"ephemeral legacy", message.SumFlags(message.FlagEphemeral), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := MessageResponse{Content: "some text", Embeds: embeds, Components: components, Flags: tt.flags} + data := res.IntoEditMessageData() + + require.Len(t, data.Components, 1) + require.Equal(t, tt.flags, data.Flags) + + if tt.wantLegacy { + require.Equal(t, "some text", data.Content) + require.Len(t, data.Embeds, 1) + return + } + + require.Empty(t, data.Content) + require.NotNil(t, data.Embeds, "Discord requires an empty array, not null, on a V2 message") + require.Empty(t, data.Embeds) + }) + } +} diff --git a/bot/logic/claim.go b/bot/logic/claim.go index bf29e41f..703bb653 100644 --- a/bot/logic/claim.go +++ b/bot/logic/claim.go @@ -8,6 +8,7 @@ import ( "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/channel" "github.com/TicketsBot-cloud/gdl/objects/channel/embed" + "github.com/TicketsBot-cloud/gdl/objects/channel/message" "github.com/TicketsBot-cloud/gdl/objects/guild/emoji" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/gdl/permission" @@ -686,68 +687,86 @@ func UpdateWelcomeMessageClaimButton(ctx context.Context, worker *worker.Context return nil } - // Find and update the button - updated := false - for i, comp := range msg.Components { - if comp.Type == component.ComponentActionRow { - row := comp.ComponentData.(component.ActionRow) - - for j, btnComp := range row.Components { - if btnComp.Type == component.ComponentButton { - btn := btnComp.ComponentData.(component.Button) - - if claimed && btn.CustomId == "claim" { - // Replace claim button with unclaim button - row.Components[j] = component.BuildButton(component.Button{ - Label: cmd.GetMessage(i18n.TitleUnclaim), - CustomId: "unclaim", - Style: component.ButtonStyleSecondary, - Emoji: &emoji.Emoji{Name: "🙋‍♂️"}, - }) - updated = true - break - } else if !claimed && btn.CustomId == "unclaim" { - // Replace unclaim button with claim button - row.Components[j] = component.BuildButton(component.Button{ - Label: cmd.GetMessage(i18n.TitleClaim), - CustomId: "claim", - Style: component.ButtonStyleSuccess, - Emoji: &emoji.Emoji{Name: "🙋‍♂️"}, - }) - updated = true - break - } - } - } - - if updated { - msg.Components[i] = component.Component{ - Type: component.ComponentActionRow, - ComponentData: row, - } - break - } - } + replacement := component.BuildButton(component.Button{ + Label: cmd.GetMessage(i18n.TitleClaim), + CustomId: "claim", + Style: component.ButtonStyleSuccess, + Emoji: &emoji.Emoji{Name: "🙋‍♂️"}, + }) + fromId := "unclaim" + if claimed { + replacement = component.BuildButton(component.Button{ + Label: cmd.GetMessage(i18n.TitleUnclaim), + CustomId: "unclaim", + Style: component.ButtonStyleSecondary, + Emoji: &emoji.Emoji{Name: "🙋‍♂️"}, + }) + fromId = "claim" } - // If no button was found to update, nothing to do - if !updated { + if !replaceButton(msg.Components, fromId, replacement) { return nil } - // Convert embeds to pointers - embeds := make([]*embed.Embed, len(msg.Embeds)) - for i := range msg.Embeds { - embeds[i] = &msg.Embeds[i] + editData := welcomeMessageEditData(msg) + + _, err = worker.EditMessage(*ticket.ChannelId, *ticket.WelcomeMessageId, editData) + return err +} + +// replaceButton swaps the first top-level action row button with the given custom id. +// The bot's own row is always appended at the top level, including on V2 messages. +func replaceButton(components []component.Component, customId string, replacement component.Component) bool { + for _, comp := range components { + row, ok := comp.ComponentData.(component.ActionRow) + if !ok { + continue + } + + for j, btnComp := range row.Components { + btn, ok := btnComp.ComponentData.(component.Button) + if ok && btn.CustomId == customId { + row.Components[j] = replacement + return true + } + } } - // Edit the message with updated components - editData := rest.EditMessageData{ + return false +} + +// ShouldRewriteClassicWelcome reports whether /switchpanel may replace the welcome +// embed. Only an embed can be rewritten in place, so a V2 message on either side is +// left as it is. +func ShouldRewriteClassicWelcome(msg message.Message, newPanel *database.Panel) bool { + return !IsComponentsV2Message(msg) && !newPanel.WelcomeMessageUsesComponentsV2 +} + +func IsComponentsV2Message(msg message.Message) bool { + return msg.Flags&int(message.FlagComponentsV2) != 0 +} + +// welcomeMessageEditData rebuilds an edit from an existing welcome message. Discord +// rejects an edit that drops IS_COMPONENTS_V2 and requires embeds to be empty on V2 +// messages. V2 text is mention-parsed, and an edit without allowed_mentions re-parses +// it with default allowances, which would ping from form answers. +func welcomeMessageEditData(msg message.Message) rest.EditMessageData { + data := rest.EditMessageData{ Content: msg.Content, - Embeds: embeds, + Flags: uint(msg.Flags), Components: msg.Components, } - _, err = worker.EditMessage(*ticket.ChannelId, *ticket.WelcomeMessageId, editData) - return err + if IsComponentsV2Message(msg) { + data.Embeds = []*embed.Embed{} + data.AllowedMentions = &message.AllowedMention{} + return data + } + + data.Embeds = make([]*embed.Embed, len(msg.Embeds)) + for i := range msg.Embeds { + data.Embeds[i] = &msg.Embeds[i] + } + + return data } diff --git a/bot/logic/welcomemessage.go b/bot/logic/welcomemessage.go index 59a5912e..3298b2fe 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -2,6 +2,7 @@ package logic import ( "context" + "encoding/json" "errors" "fmt" "regexp" @@ -16,6 +17,7 @@ import ( "github.com/TicketsBot-cloud/common/sentry" "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/channel/embed" + "github.com/TicketsBot-cloud/gdl/objects/channel/message" "github.com/TicketsBot-cloud/gdl/objects/guild/emoji" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/gdl/rest" @@ -44,6 +46,15 @@ func SendWelcomeMessage( // Only custom integration placeholders for now - prevent making duplicate requests additionalPlaceholders map[string]string, ) (uint64, error) { + if tree := loadWelcomeV2Tree(ctx, cmd, ticket, panel); tree != nil { + msgId, err := sendWelcomeMessageV2(ctx, cmd, ticket, panel, tree, formData, additionalPlaceholders) + if err == nil { + return msgId, nil + } + + logging.WarnWithContext(err, cmd.ToErrorContext(), zap.Uint64("guild_id", ticket.GuildId), zap.Int("panel_id", panel.PanelId)) + } + // Build embeds welcomeMessageEmbed, err := BuildWelcomeMessageEmbed(ctx, cmd, ticket, subject, panel, additionalPlaceholders) if err != nil { @@ -126,6 +137,214 @@ func SendWelcomeMessage( return msg.Id, nil } +// welcomeV2Tree returns the stored Components V2 tree when the V2 welcome should be +// sent, or nil when the classic welcome applies. Nothing is cleared on the panel, so +// V2 resumes as soon as paid premium returns. +func welcomeV2Tree(panel *database.Panel, paidPremium bool) ([]component.Component, error) { + if panel == nil || !panel.WelcomeMessageUsesComponentsV2 || !paidPremium { + return nil, nil + } + + // Older rows store NULL where newer ones store an empty array. + if panel.WelcomeMessageComponents == nil { + return nil, nil + } + + var tree []component.Component + if err := json.Unmarshal([]byte(*panel.WelcomeMessageComponents), &tree); err != nil { + return nil, fmt.Errorf("unmarshal welcome message components: %w", err) + } + + if len(tree) == 0 { + return nil, nil + } + + return tree, nil +} + +// hasPaidPremium mirrors FooterPolicyForContext: voting premium does not count. +func hasPaidPremium(tier premium.PremiumTier, source model.EntitlementSource) bool { + return tier != premium.None && source != model.EntitlementSourceVoting +} + +// loadWelcomeV2Tree resolves the premium source only for panels that opted into V2, so +// classic panels never pay for the extra lookup. +func loadWelcomeV2Tree(ctx context.Context, cmd registry.CommandContext, ticket database.Ticket, panel *database.Panel) []component.Component { + if panel == nil || !panel.WelcomeMessageUsesComponentsV2 || cmd.PremiumTier() == premium.None { + return nil + } + + tier, source, err := utils.PremiumClient.GetTierByGuildIdWithSource(ctx, cmd.GuildId(), cmd.Worker().Token, cmd.Worker().RateLimiter) + if err != nil { + logging.WarnWithContext(err, cmd.ToErrorContext(), zap.Uint64("guild_id", ticket.GuildId), zap.Int("panel_id", panel.PanelId)) + return nil + } + + tree, err := welcomeV2Tree(panel, hasPaidPremium(tier, source)) + if err != nil { + logging.WarnWithContext(err, cmd.ToErrorContext(), zap.Uint64("guild_id", ticket.GuildId), zap.Int("panel_id", panel.PanelId)) + return nil + } + + return tree +} + +// sendWelcomeMessageV2 sends a welcome message built from a panel's Components V2 tree, +// rather than the classic embed-based welcome message. +func sendWelcomeMessageV2( + ctx context.Context, + cmd registry.CommandContext, + ticket database.Ticket, + panel *database.Panel, + tree []component.Component, + formData map[database.FormInput]string, + // Only custom integration placeholders for now - prevent making duplicate requests + additionalPlaceholders map[string]string, +) (uint64, error) { + if ticket.ChannelId == nil { + return 0, fmt.Errorf("channel is nil") + } + + // Substitution happens before the form answers are appended, so ticket-opener + // input is never treated as a placeholder. + tree = transformComponentText(tree, componentTextTransform{ + Text: func(s string) string { + return DoPlaceholderSubstitutions(ctx, s, cmd.Worker(), ticket, additionalPlaceholders) + }, + Label: func(s string) string { + return DoPlainTextPlaceholderSubstitutions(ctx, s, cmd.Worker(), ticket, additionalPlaceholders) + }, + Url: func(s string) string { + return replaceAvatarPlaceholder(cmd.Worker(), ticket, s) + }, + }) + + var buttons []component.Component + if !panel.HideCloseButton { + buttons = append(buttons, component.BuildButton(component.Button{ + Label: cmd.GetMessage(i18n.TitleClose), + CustomId: "close", + Style: component.ButtonStyleDanger, + Emoji: &emoji.Emoji{Name: "🔒"}, + })) + } + + if !panel.HideCloseWithReasonButton { + buttons = append(buttons, component.BuildButton(component.Button{ + Label: cmd.GetMessage(i18n.TitleCloseWithReason), + CustomId: "close_with_reason", + Style: component.ButtonStyleDanger, + Emoji: &emoji.Emoji{Name: "🔒"}, + })) + } + + if !panel.HideClaimButton && !ticket.IsThread { + buttons = append(buttons, component.BuildButton(component.Button{ + Label: cmd.GetMessage(i18n.TitleClaim), + CustomId: "claim", + Style: component.ButtonStyleSuccess, + Emoji: &emoji.Emoji{Name: "🙋‍♂️"}, + })) + } + + finalTree := buildWelcomeV2Components(tree, getFormDataFields(formData), buttons) + + // Form answers are ticket-opener input and V2 text is mention-parsed, unlike embed + // fields. CreateMessageData sends allowed_mentions without omitempty, so the zero + // value goes out as an explicit empty object and suppresses every mention. + data := rest.CreateMessageData{ + Flags: message.SumFlags(message.FlagComponentsV2), + Components: finalTree, + } + + msg, err := cmd.Worker().CreateMessageComplex(*ticket.ChannelId, data) + if err != nil { + return 0, err + } + + return msg.Id, nil +} + +func buildWelcomeV2Components(tree []component.Component, fields []embed.EmbedField, buttons []component.Component) []component.Component { + out := make([]component.Component, 0, len(tree)+len(fields)+1) + out = append(out, tree...) + + for _, field := range fields { + out = append(out, component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("**%s**\n%s", field.Name, utils.EscapeMarkdown(field.Value)), + })) + } + + if len(buttons) > 0 { + out = append(out, component.BuildActionRow(buttons...)) + } + + return out +} + +// componentTextTransform rewrites the authored strings of a component tree. Url is +// applied to media and link button URLs, matching the classic path, which only +// resolves the avatar placeholder there. +type componentTextTransform struct { + Text func(string) string + Label func(string) string + Url func(string) string +} + +// transformComponentText returns a rewritten copy of the tree and leaves the input untouched. +func transformComponentText(components []component.Component, t componentTextTransform) []component.Component { + if components == nil { + return nil + } + + out := make([]component.Component, len(components)) + for i, c := range components { + out[i] = transformComponentTextOne(c, t) + } + + return out +} + +func transformComponentTextOne(c component.Component, t componentTextTransform) component.Component { + switch data := c.ComponentData.(type) { + case component.TextDisplay: + data.Content = t.Text(data.Content) + return component.BuildTextDisplay(data) + case component.Section: + data.Components = transformComponentText(data.Components, t) + if data.Accessory.ComponentData != nil { + data.Accessory = transformComponentTextOne(data.Accessory, t) + } + return component.BuildSection(data) + case component.Container: + data.Components = transformComponentText(data.Components, t) + return component.BuildContainer(data) + case component.ActionRow: + data.Components = transformComponentText(data.Components, t) + return component.BuildActionRow(data.Components...) + case component.Button: + data.Label = t.Label(data.Label) + if data.Url != nil { + resolved := t.Url(*data.Url) + data.Url = &resolved + } + return component.BuildButton(data) + case component.Thumbnail: + data.Media.Url = t.Url(data.Media.Url) + return component.BuildThumbnail(data) + case component.MediaGallery: + items := make([]component.MediaGalleryItem, len(data.Items)) + copy(items, data.Items) + for i := range items { + items[i].Media.Url = t.Url(items[i].Media.Url) + } + data.Items = items + return component.BuildMediaGallery(data) + } + + return c +} + func BuildWelcomeMessageEmbed( ctx context.Context, cmd registry.CommandContext, diff --git a/bot/logic/welcomemessage_v2_test.go b/bot/logic/welcomemessage_v2_test.go new file mode 100644 index 00000000..94092296 --- /dev/null +++ b/bot/logic/welcomemessage_v2_test.go @@ -0,0 +1,290 @@ +package logic + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/TicketsBot-cloud/common/model" + "github.com/TicketsBot-cloud/common/premium" + "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/objects/channel/embed" + "github.com/TicketsBot-cloud/gdl/objects/channel/message" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/stretchr/testify/require" +) + +func strPtr(s string) *string { return &s } + +func TestHasPaidPremium(t *testing.T) { + tests := []struct { + name string + tier premium.PremiumTier + source model.EntitlementSource + want bool + }{ + {"no premium", premium.None, model.EntitlementSourceVoting, false}, + {"no premium with paid source", premium.None, model.EntitlementSourceDiscord, false}, + {"voting premium", premium.Premium, model.EntitlementSourceVoting, false}, + {"paid premium", premium.Premium, model.EntitlementSourceDiscord, true}, + {"paid whitelabel", premium.Whitelabel, model.EntitlementSourcePatreon, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, hasPaidPremium(tt.tier, tt.source)) + }) + } +} + +func TestWelcomeV2Tree(t *testing.T) { + validTree := `[{"type":10,"content":"hello"}]` + + tests := []struct { + name string + panel *database.Panel + paid bool + wantLen int + wantError bool + }{ + {"nil panel", nil, true, 0, false}, + {"panel not using v2", &database.Panel{WelcomeMessageComponents: strPtr(validTree)}, true, 0, false}, + {"paid with tree", &database.Panel{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: strPtr(validTree)}, true, 1, false}, + {"no paid premium", &database.Panel{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: strPtr(validTree)}, false, 0, false}, + {"null tree", &database.Panel{WelcomeMessageUsesComponentsV2: true}, true, 0, false}, + {"empty array tree", &database.Panel{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: strPtr("[]")}, true, 0, false}, + {"json null tree", &database.Panel{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: strPtr("null")}, true, 0, false}, + {"malformed tree", &database.Panel{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: strPtr("{")}, true, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tree, err := welcomeV2Tree(tt.panel, tt.paid) + if tt.wantError { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Len(t, tree, tt.wantLen) + }) + } +} + +func TestTransformComponentText(t *testing.T) { + linkUrl := "https://example.com/%user%" + tree := []component.Component{ + component.BuildContainer(component.Container{ + Components: []component.Component{ + component.BuildSection(component.Section{ + Components: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "section %user%"}), + }, + Accessory: component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "%avatar_url%"}}), + }), + component.BuildActionRow( + component.BuildButton(component.Button{Label: "Docs %user%", Style: component.ButtonStyleLink, Url: &linkUrl}), + ), + component.BuildMediaGallery(component.MediaGallery{Items: []component.MediaGalleryItem{{Media: component.UnfurledMediaItem{Url: "%avatar_url%"}}}}), + }, + }), + component.BuildTextDisplay(component.TextDisplay{Content: "top %user%"}), + component.BuildSeparator(component.Separator{}), + } + + got := transformComponentText(tree, componentTextTransform{ + Text: func(s string) string { return strings.ReplaceAll(s, "%user%", "TEXT") }, + Label: func(s string) string { return strings.ReplaceAll(s, "%user%", "LABEL") }, + Url: func(s string) string { return strings.ReplaceAll(s, "%avatar_url%", "AVATAR") }, + }) + + container := got[0].ComponentData.(component.Container) + section := container.Components[0].ComponentData.(component.Section) + require.Equal(t, "section TEXT", section.Components[0].ComponentData.(component.TextDisplay).Content) + require.Equal(t, "AVATAR", section.Accessory.ComponentData.(component.Thumbnail).Media.Url) + + button := container.Components[1].ComponentData.(component.ActionRow).Components[0].ComponentData.(component.Button) + require.Equal(t, "Docs LABEL", button.Label) + require.Equal(t, "https://example.com/%user%", *button.Url, "only the avatar placeholder is resolved in URLs") + + gallery := container.Components[2].ComponentData.(component.MediaGallery) + require.Equal(t, "AVATAR", gallery.Items[0].Media.Url) + + require.Equal(t, "top TEXT", got[1].ComponentData.(component.TextDisplay).Content) + require.Equal(t, component.ComponentSeparator, got[2].Type) + + // The stored tree must not be mutated, as it is shared with the panel row. + require.Equal(t, "top %user%", tree[1].ComponentData.(component.TextDisplay).Content) + require.Equal(t, "Docs %user%", tree[0].ComponentData.(component.Container).Components[1].ComponentData.(component.ActionRow).Components[0].ComponentData.(component.Button).Label) + + require.Nil(t, transformComponentText(nil, componentTextTransform{})) +} + +func TestBuildWelcomeV2ComponentsAppendsEveryAnswer(t *testing.T) { + authored := []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "welcome"})} + buttons := []component.Component{component.BuildButton(component.Button{CustomId: "close"})} + + tests := []struct { + name string + answers int + buttons []component.Component + wantTotal int + }{ + {"no answers", 0, buttons, 2}, + {"no answers or buttons", 0, nil, 1}, + {"under the old budget", 3, buttons, 5}, + {"over the old budget", 45, buttons, 47}, + {"no buttons", 12, nil, 13}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := make([]embed.EmbedField, tt.answers) + for i := range fields { + fields[i] = embed.EmbedField{Name: "Q", Value: "%user% *answer*"} + } + + got := buildWelcomeV2Components(authored, fields, tt.buttons) + require.Len(t, got, tt.wantTotal) + + if tt.answers > 0 { + answer := got[len(authored)].ComponentData.(component.TextDisplay).Content + require.Contains(t, answer, "%user%", "form answers are never substituted") + } + + if len(tt.buttons) > 0 { + require.Equal(t, component.ComponentActionRow, got[len(got)-1].Type) + } + }) + } +} + +func v2ClaimRow() component.Component { + return component.BuildActionRow( + component.BuildButton(component.Button{CustomId: "close"}), + component.BuildButton(component.Button{CustomId: "claim"}), + ) +} + +func TestWelcomeMessageEditData(t *testing.T) { + v2Flags := int(message.FlagComponentsV2) + + tests := []struct { + name string + msg message.Message + wantFlags float64 + wantEmbeds int + wantMentions bool + }{ + { + name: "components v2 keeps flags, empties embeds and blocks mentions", + msg: message.Message{ + Flags: v2Flags, + Embeds: []embed.Embed{{Title: "stale"}}, + Components: []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "hi @everyone"}), v2ClaimRow()}, + }, + wantFlags: float64(v2Flags), + wantEmbeds: 0, + wantMentions: true, + }, + { + name: "classic keeps embeds and leaves mentions alone", + msg: message.Message{ + Flags: 0, + Embeds: []embed.Embed{{Title: "welcome"}}, + Components: []component.Component{v2ClaimRow()}, + }, + wantFlags: 0, + wantEmbeds: 1, + wantMentions: false, + }, + { + name: "classic without embeds sends an empty array", + msg: message.Message{Components: []component.Component{v2ClaimRow()}}, + wantEmbeds: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, err := json.Marshal(welcomeMessageEditData(tt.msg)) + require.NoError(t, err) + + var body map[string]any + require.NoError(t, json.Unmarshal(raw, &body)) + + require.Equal(t, tt.wantFlags, body["flags"]) + require.IsType(t, []any{}, body["embeds"], "embeds must serialise as an array, not null") + require.Len(t, body["embeds"], tt.wantEmbeds) + + mentions, present := body["allowed_mentions"] + require.Equal(t, tt.wantMentions, present) + if present { + require.Equal(t, map[string]any{"replied_user": false}, mentions) + } + }) + } +} + +func TestReplaceButton(t *testing.T) { + unclaim := component.BuildButton(component.Button{CustomId: "unclaim"}) + + tests := []struct { + name string + components []component.Component + fromId string + want bool + }{ + {"classic message", []component.Component{v2ClaimRow()}, "claim", true}, + { + "v2 message with container and text before the row", + []component.Component{ + component.BuildContainer(component.Container{Components: []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "x"})}}), + component.BuildTextDisplay(component.TextDisplay{Content: "answer"}), + v2ClaimRow(), + }, + "claim", + true, + }, + {"button absent", []component.Component{v2ClaimRow()}, "unclaim", false}, + {"no components", nil, "claim", false}, + {"no action row", []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: "x"})}, "claim", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, replaceButton(tt.components, tt.fromId, unclaim)) + + if tt.want { + row := tt.components[len(tt.components)-1].ComponentData.(component.ActionRow) + require.Equal(t, "unclaim", row.Components[1].ComponentData.(component.Button).CustomId) + require.Equal(t, "close", row.Components[0].ComponentData.(component.Button).CustomId) + } + }) + } +} + +func TestShouldRewriteClassicWelcome(t *testing.T) { + classicMsg := message.Message{} + v2Msg := message.Message{Flags: int(message.FlagComponentsV2)} + classicPanel := &database.Panel{} + v2Panel := &database.Panel{WelcomeMessageUsesComponentsV2: true} + + tests := []struct { + name string + msg message.Message + panel *database.Panel + want bool + }{ + {"classic message, classic panel", classicMsg, classicPanel, true}, + {"classic message, v2 panel", classicMsg, v2Panel, false}, + {"v2 message, classic panel", v2Msg, classicPanel, false}, + {"v2 message, v2 panel", v2Msg, v2Panel, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, ShouldRewriteClassicWelcome(tt.msg, tt.panel)) + }) + } +} diff --git a/go.mod b/go.mod index e8f33ccf..4571c130 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/TicketsBot-cloud/worker go 1.26.0 -// replace github.com/TicketsBot-cloud/database => ../database +replace github.com/TicketsBot-cloud/database => ../database // replace github.com/TicketsBot-cloud/common => ../common @@ -16,7 +16,7 @@ require ( cloud.google.com/go/profiler v0.4.2 github.com/TicketsBot-cloud/archiverclient v0.0.0-20251015181023-f0b66a074704 github.com/TicketsBot-cloud/common v0.0.0-20260927072029-424264fc4be2 - github.com/TicketsBot-cloud/database v0.0.0-20261002063446-01dca52a987e + github.com/TicketsBot-cloud/database v0.0.0-20261008170428-3b63509d3da8 github.com/TicketsBot-cloud/gdl v0.0.0-20260927072153-8dbf0b284f57 github.com/caarlos0/env/v10 v10.0.0 github.com/elliotchance/orderedmap v1.8.0 diff --git a/go.sum b/go.sum index c4d63865..acf4748c 100644 --- a/go.sum +++ b/go.sum @@ -153,6 +153,7 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= @@ -173,6 +174,7 @@ github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5W github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= diff --git a/restwrapper.go b/restwrapper.go index 53e71c6c..8558ccb9 100644 --- a/restwrapper.go +++ b/restwrapper.go @@ -42,7 +42,7 @@ func (ctx *Context) GetChannel(channelId uint64) (channel.Channel, error) { func (ctx *Context) ModifyChannel(reqCtx context.Context, channelId uint64, data rest.ModifyChannelData) (channel.Channel, error) { channel, err := rest.ModifyChannel(reqCtx, ctx.Token, ctx.RateLimiter, channelId, data) - if ctx.Cache.Options().Channels && err != nil { + if ctx.Cache.Options().Channels && err == nil { go ctx.Cache.StoreChannel(context.Background(), channel) }