diff --git a/bot/button/handlers/closewithreason_test.go b/bot/button/handlers/closewithreason_test.go new file mode 100644 index 00000000..28751e42 --- /dev/null +++ b/bot/button/handlers/closewithreason_test.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "encoding/json" + "testing" + + "github.com/TicketsBot-cloud/database" + "github.com/stretchr/testify/require" +) + +func TestPickCloseReason(t *testing.T) { + require.Equal(t, "Resolved", pickCloseReason("typed text", []string{"Resolved"})) + require.Equal(t, "typed text", pickCloseReason(" typed text ", nil)) + require.Equal(t, "", pickCloseReason(" ", []string{})) +} + +func TestBuildCloseReasonSelectMenu(t *testing.T) { + current := "resolved" + + tests := []struct { + name string + closeReasons database.PanelCloseReasons + current *string + want string + }{ + { + name: "custom allowed", + closeReasons: database.PanelCloseReasons{Reasons: []string{"Resolved", "Duplicate"}, AllowCustom: true}, + want: `{"type":3,"custom_id":"preset","options":[{"label":"Resolved","value":"Resolved","default":false},{"label":"Duplicate","value":"Duplicate","default":false}],"min_values":0,"max_values":1,"disabled":false,"required":false}`, + }, + { + name: "custom not allowed", + closeReasons: database.PanelCloseReasons{Reasons: []string{"Resolved"}, AllowCustom: false}, + want: `{"type":3,"custom_id":"preset","options":[{"label":"Resolved","value":"Resolved","default":false}],"max_values":1,"disabled":false,"required":true}`, + }, + { + name: "current reason selected", + closeReasons: database.PanelCloseReasons{Reasons: []string{"Resolved", "Duplicate"}, AllowCustom: true}, + current: ¤t, + want: `{"type":3,"custom_id":"preset","options":[{"label":"Resolved","value":"Resolved","default":true},{"label":"Duplicate","value":"Duplicate","default":false}],"min_values":0,"max_values":1,"disabled":false,"required":false}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + marshalled, err := json.Marshal(buildCloseReasonSelectMenu(tc.closeReasons, tc.current)) + require.NoError(t, err) + require.JSONEq(t, tc.want, string(marshalled)) + }) + } +} diff --git a/bot/button/handlers/closewithreasonmodal.go b/bot/button/handlers/closewithreasonmodal.go index 05b9225a..ef2094c2 100644 --- a/bot/button/handlers/closewithreasonmodal.go +++ b/bot/button/handlers/closewithreasonmodal.go @@ -3,6 +3,8 @@ package handlers import ( "time" + "github.com/TicketsBot-cloud/common/sentry" + "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/interaction" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/worker/bot/button" @@ -11,6 +13,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" "github.com/TicketsBot-cloud/worker/i18n" ) @@ -47,6 +50,20 @@ func (h *CloseWithReasonModalHandler) Execute(ctx *context.ButtonContext) { return } + closeReasons, err := logic.GetPanelCloseReasons(ctx, ticket) + if err != nil { + sentry.ErrorWithContext(err, ctx.ToErrorContext()) + } else if len(closeReasons.Reasons) > 0 { + ctx.Modal(button.ResponseModal{ + Data: interaction.ModalResponseData{ + CustomId: "close_with_reason_submit", + Title: i18n.TitleClose.GetFromGuild(ctx.GuildId()), + Components: buildCloseReasonPresetComponents(ctx.GuildId(), closeReasons), + }, + }) + return + } + ctx.Modal(button.ResponseModal{ Data: interaction.ModalResponseData{ CustomId: "close_with_reason_submit", @@ -67,3 +84,54 @@ func (h *CloseWithReasonModalHandler) Execute(ctx *context.ButtonContext) { }, }) } + +func buildCloseReasonPresetComponents(guildId uint64, closeReasons database.PanelCloseReasons) []component.Component { + components := []component.Component{ + component.BuildLabel(component.Label{ + Label: i18n.Reason.GetFromGuild(guildId), + Component: buildCloseReasonSelectMenu(closeReasons, nil), + }), + } + + if !closeReasons.AllowCustom { + return components + } + + return append(components, component.BuildLabel(component.Label{ + Label: i18n.MessageCloseReasonCustom.GetFromGuild(guildId), + Description: utils.Ptr(i18n.MessageCloseReasonCustomDescription.GetFromGuild(guildId)), + Component: component.BuildInputText(component.InputText{ + Style: component.TextStyleParagraph, + CustomId: "reason", + Placeholder: utils.Ptr(i18n.MessageCloseReasonPlaceholder.GetFromGuild(guildId)), + MaxLength: utils.Ptr(uint32(1024)), + Required: utils.Ptr(false), + }), + })) +} + +func buildCloseReasonSelectMenu(closeReasons database.PanelCloseReasons, current *string) component.Component { + selected, hasSelected := closeReasons.Match(utils.ValueOrZero(current)) + + options := make([]component.SelectOption, len(closeReasons.Reasons)) + for i, reason := range closeReasons.Reasons { + options[i] = component.SelectOption{ + Label: reason, + Value: reason, + Default: hasSelected && reason == selected, + } + } + + selectMenu := component.SelectMenu{ + CustomId: "preset", + Options: options, + MaxValues: utils.Ptr(1), + Required: utils.Ptr(!closeReasons.AllowCustom), + } + + if closeReasons.AllowCustom { + selectMenu.MinValues = utils.Ptr(0) + } + + return component.BuildSelectMenu(selectMenu) +} diff --git a/bot/button/handlers/closewithreasonsubmit.go b/bot/button/handlers/closewithreasonsubmit.go index 6590b72b..c499225a 100644 --- a/bot/button/handlers/closewithreasonsubmit.go +++ b/bot/button/handlers/closewithreasonsubmit.go @@ -2,13 +2,15 @@ package handlers import ( "fmt" + "strings" - "github.com/TicketsBot-cloud/gdl/objects/interaction" "github.com/TicketsBot-cloud/worker/bot/button/registry" "github.com/TicketsBot-cloud/worker/bot/button/registry/matcher" "github.com/TicketsBot-cloud/worker/bot/command/context" "github.com/TicketsBot-cloud/worker/bot/constants" + "github.com/TicketsBot-cloud/worker/bot/customisation" "github.com/TicketsBot-cloud/worker/bot/logic" + "github.com/TicketsBot-cloud/worker/i18n" ) type CloseWithReasonSubmitHandler struct{} @@ -25,39 +27,40 @@ func (h *CloseWithReasonSubmitHandler) Properties() registry.Properties { } func (h *CloseWithReasonSubmitHandler) Execute(ctx *context.ModalContext) { - data := ctx.Interaction.Data - - // Get the reason - if len(data.Components) == 0 { // No action rows - ctx.HandleError(fmt.Errorf("No action rows found in modal components")) - return - } - - actionRow := data.Components[0] - if len(actionRow.Components) == 0 && actionRow.Component == nil { // Text input missing + reason, hasInput := ctx.GetInput("reason") + presets, hasSelect := ctx.GetValues("preset") + if !hasInput && !hasSelect { ctx.HandleError(fmt.Errorf("Modal missing text input")) return } - var textInput interaction.ModalSubmitInteractionComponentData - - if actionRow.Component != nil { - textInput = *actionRow.Component - } else { - textInput = actionRow.Components[0] + if hasSelect { + reason = pickCloseReason(reason, presets) + if reason == "" { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageCloseReasonMissing) + return + } } - if textInput.CustomId != "reason" { - ctx.HandleError(fmt.Errorf("Text input custom ID mismatch")) + // This must be malicious + if len(reason) > 1024 { + ctx.HandleError(fmt.Errorf("Reason is too long")) return } - // This must be malicious - if len(textInput.Value) > 1024 { - ctx.HandleError(fmt.Errorf("Reason is too long")) + reason, ok := logic.ResolveChannelCloseReason(ctx.Context, ctx, reason) + if !ok { return } ctx.Ack() - logic.CloseTicket(ctx.Context, ctx, &textInput.Value, false) + logic.CloseTicket(ctx.Context, ctx, &reason, false) +} + +func pickCloseReason(text string, presets []string) string { + if len(presets) > 0 { + return presets[0] + } + + return strings.TrimSpace(text) } diff --git a/bot/button/handlers/editclosereasonmodal.go b/bot/button/handlers/editclosereasonmodal.go index 328616af..e5c0e25d 100644 --- a/bot/button/handlers/editclosereasonmodal.go +++ b/bot/button/handlers/editclosereasonmodal.go @@ -8,6 +8,8 @@ import ( "time" "github.com/TicketsBot-cloud/common/permission" + "github.com/TicketsBot-cloud/common/sentry" + "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/interaction" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/worker/bot/button" @@ -73,6 +75,20 @@ func (h *EditCloseReasonModalHandler) Execute(ctx *context.ButtonContext) { return } + closeReasons, err := dbclient.Client.PanelCloseReasons.GetByTicket(ctx.Context, guildId, ticketId) + if err != nil { + sentry.ErrorWithContext(err, ctx.ToErrorContext()) + } else if len(closeReasons.Reasons) > 0 { + ctx.Modal(button.ResponseModal{ + Data: interaction.ModalResponseData{ + CustomId: fmt.Sprintf("edit_close_reason_submit_%d_%d", guildId, ticketId), + Title: "Edit Close Reason", + Components: buildEditCloseReasonPresetComponents(closeMetadata.Reason, closeReasons), + }, + }) + return + } + ctx.Modal(button.ResponseModal{ Data: interaction.ModalResponseData{ CustomId: fmt.Sprintf("edit_close_reason_submit_%d_%d", guildId, ticketId), @@ -93,3 +109,35 @@ func (h *EditCloseReasonModalHandler) Execute(ctx *context.ButtonContext) { }, }) } + +func buildEditCloseReasonPresetComponents(current *string, closeReasons database.PanelCloseReasons) []component.Component { + components := []component.Component{ + component.BuildLabel(component.Label{ + Label: "Close Reason", + Description: utils.Ptr("Update the reason this ticket was closed"), + Component: buildCloseReasonSelectMenu(closeReasons, current), + }), + } + + if !closeReasons.AllowCustom { + return components + } + + value := current + if _, isPreset := closeReasons.Match(utils.ValueOrZero(current)); isPreset { + value = nil + } + + return append(components, component.BuildLabel(component.Label{ + Label: "Custom Reason", + Description: utils.Ptr("Only used if no reason is selected above"), + Component: component.BuildInputText(component.InputText{ + Style: component.TextStyleParagraph, + CustomId: "reason", + Placeholder: utils.Ptr("No reason specified"), + MaxLength: utils.Ptr(uint32(1024)), + Required: utils.Ptr(false), + Value: value, + }), + })) +} diff --git a/bot/button/handlers/editclosereasonsubmit.go b/bot/button/handlers/editclosereasonsubmit.go index da10385f..62c2930c 100644 --- a/bot/button/handlers/editclosereasonsubmit.go +++ b/bot/button/handlers/editclosereasonsubmit.go @@ -64,12 +64,21 @@ func (h *EditCloseReasonSubmitHandler) Execute(ctx *context.ModalContext) { return } - reason, ok := ctx.GetInput("reason") - if !ok { + reason, hasInput := ctx.GetInput("reason") + presets, hasSelect := ctx.GetValues("preset") + if !hasInput && !hasSelect { ctx.HandleError(fmt.Errorf("reason input not found in modal submission")) return } + if hasSelect { + reason = pickCloseReason(reason, presets) + if reason == "" { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageCloseReasonMissing) + return + } + } + if len(reason) > 1024 { ctx.HandleError(fmt.Errorf("reason too long")) return @@ -85,6 +94,12 @@ func (h *EditCloseReasonSubmitHandler) Execute(ctx *context.ModalContext) { return } + reason, ok := logic.ResolveCloseReason(ctx.Context, ctx, ticket, reason) + if !ok { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageCloseReasonNotPredefined) + return + } + existing, _, err := dbclient.Client.CloseReason.Get(ctx.Context, guildId, ticketId) if err != nil { ctx.HandleError(err) diff --git a/bot/command/context/modalcontext.go b/bot/command/context/modalcontext.go index 745d279f..5e1f2040 100644 --- a/bot/command/context/modalcontext.go +++ b/bot/command/context/modalcontext.go @@ -99,6 +99,30 @@ func (c *ModalContext) GetInput(customId string) (string, bool) { return "", false } +func (c *ModalContext) GetValues(customId string) ([]string, bool) { + for _, c := range c.Interaction.Data.Components { + if (c.Type != component.ComponentActionRow && c.Type != component.ComponentLabel) || (len(c.Components) != 1 && c.Component == nil) { + continue + } + + input := c.Component + + if input == nil && len(c.Components) > 0 { + input = &c.Components[0] + } + + if input.Type != component.ComponentSelectMenu { + continue + } + + if input.CustomId == customId { + return input.Values, true + } + } + + return nil, false +} + func (c *ModalContext) Worker() *worker.Context { return c.worker } diff --git a/bot/command/impl/tickets/close.go b/bot/command/impl/tickets/close.go index 8e6748a8..a6f8d623 100644 --- a/bot/command/impl/tickets/close.go +++ b/bot/command/impl/tickets/close.go @@ -2,6 +2,7 @@ package tickets import ( "context" + "strings" "time" "github.com/TicketsBot-cloud/common/permission" @@ -38,6 +39,15 @@ func (c CloseCommand) GetExecutor() interface{} { } func (CloseCommand) Execute(ctx registry.CommandContext, reason *string) { + if reason != nil { + canonical, ok := logic.ResolveChannelCloseReason(ctx, ctx, *reason) + if !ok { + return + } + + reason = &canonical + } + logic.CloseTicket(ctx, ctx, reason, false) } @@ -55,6 +65,14 @@ func (CloseCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoC ctx, cancel := utils.ContextTimeout(time.Millisecond * 1500) defer cancel() + if ticket.Id != 0 { + if closeReasons, err := logic.GetPanelCloseReasons(ctx, ticket); err != nil { + sentry.Error(err) + } else if len(closeReasons.Reasons) > 0 { + return closeReasonChoices(closeReasons.Reasons, value) + } + } + // If there is no text provided by the user yet, and this is a ticket channel, we can use our materialised view to // get the most common close reasons for that panel. Otherwise, perform a dynamic query to get the most common // reasons for that text for all panels. @@ -86,3 +104,22 @@ func (CloseCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoC return choices } + +func closeReasonChoices(reasons []string, value string) []interaction.ApplicationCommandOptionChoice { + value = strings.ToLower(strings.TrimSpace(value)) + + choices := make([]interaction.ApplicationCommandOptionChoice, 0, min(len(reasons), 25)) + for _, reason := range reasons { + if !strings.Contains(strings.ToLower(reason), value) { + continue + } + + choices = append(choices, utils.StringChoice(reason)) + + if len(choices) >= 25 { + break + } + } + + return choices +} diff --git a/bot/command/impl/tickets/closerequest.go b/bot/command/impl/tickets/closerequest.go index f8379892..6a8af359 100644 --- a/bot/command/impl/tickets/closerequest.go +++ b/bot/command/impl/tickets/closerequest.go @@ -18,6 +18,7 @@ import ( "github.com/TicketsBot-cloud/worker/bot/command/registry" "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" "github.com/TicketsBot-cloud/worker/i18n" ) @@ -63,6 +64,16 @@ func (CloseRequestCommand) Execute(ctx registry.CommandContext, closeDelay *int, return } + if reason != nil { + canonical, ok := logic.ResolveCloseReason(ctx, ctx, ticket, *reason) + if !ok { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageCloseReasonNotPredefined) + return + } + + reason = &canonical + } + var closeAt *time.Time = nil if closeDelay != nil { tmp := time.Now().Add(time.Hour * time.Duration(*closeDelay)) diff --git a/bot/logic/closereasons.go b/bot/logic/closereasons.go new file mode 100644 index 00000000..2fc1ec51 --- /dev/null +++ b/bot/logic/closereasons.go @@ -0,0 +1,63 @@ +package logic + +import ( + "context" + "strings" + + "github.com/TicketsBot-cloud/common/sentry" + "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/worker/bot/command/registry" + "github.com/TicketsBot-cloud/worker/bot/customisation" + "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/bot/utils" + "github.com/TicketsBot-cloud/worker/i18n" +) + +func GetPanelCloseReasons(ctx context.Context, ticket database.Ticket) (database.PanelCloseReasons, error) { + if ticket.PanelId == nil { + return database.DefaultPanelCloseReasons(), nil + } + + return dbclient.Client.PanelCloseReasons.Get(ctx, *ticket.PanelId) +} + +func ResolveCloseReason(ctx context.Context, cmd registry.CommandContext, ticket database.Ticket, reason string) (string, bool) { + if strings.TrimSpace(reason) == "" { + return reason, true + } + + closeReasons, err := GetPanelCloseReasons(ctx, ticket) + if err != nil { + sentry.ErrorWithContext(err, cmd.ToErrorContext()) + return reason, true + } + + return closeReasons.Resolve(reason) +} + +func ResolveChannelCloseReason(ctx context.Context, cmd registry.CommandContext, reason string) (string, bool) { + if strings.TrimSpace(reason) == "" { + return reason, true + } + + ticket, err := dbclient.Client.Tickets.GetByChannelAndGuild(ctx, cmd.ChannelId(), cmd.GuildId()) + if err != nil { + cmd.HandleError(err) + return "", false + } + + if ticket.Id == 0 { + return reason, true + } + + canonical, ok := ResolveCloseReason(ctx, cmd, ticket, reason) + if !ok { + if !utils.CanClose(ctx, cmd, ticket) { + cmd.Reply(customisation.Red, i18n.Error, i18n.MessageCloseNoPermission) + } else { + cmd.Reply(customisation.Red, i18n.Error, i18n.MessageCloseReasonNotPredefined) + } + } + + return canonical, ok +} diff --git a/go.mod b/go.mod index e8f33ccf..33689937 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( cloud.google.com/go/profiler v0.4.2 github.com/TicketsBot-cloud/archiverclient v0.0.0-20251015181023-f0b66a074704 github.com/TicketsBot-cloud/common v0.0.0-20260927072029-424264fc4be2 - github.com/TicketsBot-cloud/database v0.0.0-20261002063446-01dca52a987e + github.com/TicketsBot-cloud/database v0.0.0-20261008170428-3b63509d3da8 github.com/TicketsBot-cloud/gdl v0.0.0-20260927072153-8dbf0b284f57 github.com/caarlos0/env/v10 v10.0.0 github.com/elliotchance/orderedmap v1.8.0 diff --git a/go.sum b/go.sum index c4d63865..55a4e3ee 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,8 @@ github.com/TicketsBot-cloud/archiverclient v0.0.0-20251015181023-f0b66a074704 h1 github.com/TicketsBot-cloud/archiverclient v0.0.0-20251015181023-f0b66a074704/go.mod h1:Mux1bEPpOHwRw1wo6Fa6qJLJH9Erk9qv1yAIfLi1Wmw= github.com/TicketsBot-cloud/common v0.0.0-20260927072029-424264fc4be2 h1:qeebeGGdrsDXrngOVrKMDt9G3JUTJwT/IEPTB61d46s= github.com/TicketsBot-cloud/common v0.0.0-20260927072029-424264fc4be2/go.mod h1:yL+VPSYNVK5gxUkA+fbb0WdTjGfWXnr7ScfL0rwk29g= -github.com/TicketsBot-cloud/database v0.0.0-20261002063446-01dca52a987e h1:kZS42Mp+6wBhApK7zA6F1SX8ZYYG18cVRHB2ZDTvx94= -github.com/TicketsBot-cloud/database v0.0.0-20261002063446-01dca52a987e/go.mod h1:0fKn7nPX6KeljYNQtGQiuZ5aJ/TczgXvBhnbz+4GEc8= +github.com/TicketsBot-cloud/database v0.0.0-20261008170428-3b63509d3da8 h1:F5MQeYDIDdfhGpko3NZ9rcwxIhsrq7UzdafQr/oNjvE= +github.com/TicketsBot-cloud/database v0.0.0-20261008170428-3b63509d3da8/go.mod h1:0fKn7nPX6KeljYNQtGQiuZ5aJ/TczgXvBhnbz+4GEc8= github.com/TicketsBot-cloud/gdl v0.0.0-20260927072153-8dbf0b284f57 h1:cFlQplQOlJCb+JQfxny+MJTg4dMKyEAvzB50MrWRa1E= github.com/TicketsBot-cloud/gdl v0.0.0-20260927072153-8dbf0b284f57/go.mod h1:CdwBR2egPtxUXjD2CgC9ZwfuB8dz9HPePM8nuG6dt7Y= github.com/TicketsBot-cloud/logarchiver v0.0.0-20251018211319-7a7df5cacbdc h1:qTLNpCvIqM7UwZ6MdWQ9EztcDsIJfHh+VJdG+ULLEaA= diff --git a/i18n/messages.go b/i18n/messages.go index 181fd8c0..21f152b9 100644 --- a/i18n/messages.go +++ b/i18n/messages.go @@ -73,6 +73,11 @@ var ( MessageCloseCantRateStaff MessageId = "close.rate.not_allowed.staff" MessageCloseCantRateEmpty MessageId = "close.rate.not_allowed.empty" + MessageCloseReasonNotPredefined MessageId = "close.reason.not_predefined" + MessageCloseReasonMissing MessageId = "close.reason.missing" + MessageCloseReasonCustom MessageId = "close.reason.custom" + MessageCloseReasonCustomDescription MessageId = "close.reason.custom_description" + MessageTag MessageId = "commands.tag.generic" MessageTagCreateInvalidArguments MessageId = "commands.tags.create.invalid_arguments" MessageTagCreateTooLong MessageId = "commands.tags.create.too_long"