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/argument.go b/bot/command/argument.go index b12c9e11..42b52d3a 100644 --- a/bot/command/argument.go +++ b/bot/command/argument.go @@ -2,6 +2,7 @@ package command import ( "github.com/TicketsBot-cloud/gdl/objects/interaction" + "github.com/TicketsBot-cloud/worker" "github.com/TicketsBot-cloud/worker/i18n" ) @@ -14,7 +15,7 @@ type Argument struct { AutoCompleteHandler AutoCompleteHandler } -type AutoCompleteHandler func(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice +type AutoCompleteHandler func(worker *worker.Context, data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice func NewOptionalArgument(name, description string, argumentType interaction.ApplicationCommandOptionType, invalidMessage i18n.MessageId) Argument { return Argument{ 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/admin/admingenpremium.go b/bot/command/impl/admin/admingenpremium.go index 9907721c..2663830c 100644 --- a/bot/command/impl/admin/admingenpremium.go +++ b/bot/command/impl/admin/admingenpremium.go @@ -9,6 +9,7 @@ import ( "github.com/TicketsBot-cloud/common/permission" "github.com/TicketsBot-cloud/common/sentry" "github.com/TicketsBot-cloud/gdl/objects/interaction" + "github.com/TicketsBot-cloud/worker" "github.com/TicketsBot-cloud/worker/bot/command" "github.com/TicketsBot-cloud/worker/bot/command/registry" "github.com/TicketsBot-cloud/worker/bot/customisation" @@ -119,7 +120,7 @@ func (AdminGenPremiumCommand) Execute(ctx registry.CommandContext, skuIdRaw stri ))) } -func (AdminGenPremiumCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { +func (AdminGenPremiumCommand) AutoCompleteHandler(_ *worker.Context, data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) defer cancel() diff --git a/bot/command/impl/general/gdpr.go b/bot/command/impl/general/gdpr.go index d82a1d98..3f57f831 100644 --- a/bot/command/impl/general/gdpr.go +++ b/bot/command/impl/general/gdpr.go @@ -7,6 +7,7 @@ import ( "github.com/TicketsBot-cloud/common/permission" "github.com/TicketsBot-cloud/gdl/objects/interaction" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/TicketsBot-cloud/worker" "github.com/TicketsBot-cloud/worker/bot/command" "github.com/TicketsBot-cloud/worker/bot/command/registry" "github.com/TicketsBot-cloud/worker/bot/customisation" @@ -112,7 +113,7 @@ func buildButtonRow(ctx registry.CommandContext, locale *i18n.Locale, buttons [] return component.BuildActionRow(buttonComponents...) } -func (GDPRCommand) LanguageAutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { +func (GDPRCommand) LanguageAutoCompleteHandler(_ *worker.Context, data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { choices := make([]interaction.ApplicationCommandOptionChoice, 0) for _, locale := range i18n.Locales { diff --git a/bot/command/impl/kb/browse.go b/bot/command/impl/kb/browse.go index bcb316e7..a47ba451 100644 --- a/bot/command/impl/kb/browse.go +++ b/bot/command/impl/kb/browse.go @@ -9,6 +9,7 @@ import ( "github.com/TicketsBot-cloud/common/permission" "github.com/TicketsBot-cloud/common/sentry" "github.com/TicketsBot-cloud/gdl/objects/interaction" + "github.com/TicketsBot-cloud/worker" "github.com/TicketsBot-cloud/worker/bot/command" "github.com/TicketsBot-cloud/worker/bot/command/registry" "github.com/TicketsBot-cloud/worker/bot/customisation" @@ -88,7 +89,7 @@ func (KBBrowseCommand) Execute(ctx registry.CommandContext, categoryIdStr *strin } } -func (KBBrowseCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { +func (KBBrowseCommand) AutoCompleteHandler(_ *worker.Context, data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) defer cancel() diff --git a/bot/command/impl/kb/search.go b/bot/command/impl/kb/search.go index 21e77051..29e74a88 100644 --- a/bot/command/impl/kb/search.go +++ b/bot/command/impl/kb/search.go @@ -8,6 +8,7 @@ import ( "github.com/TicketsBot-cloud/common/permission" "github.com/TicketsBot-cloud/common/sentry" "github.com/TicketsBot-cloud/gdl/objects/interaction" + "github.com/TicketsBot-cloud/worker" "github.com/TicketsBot-cloud/worker/bot/command" "github.com/TicketsBot-cloud/worker/bot/command/registry" "github.com/TicketsBot-cloud/worker/bot/dbclient" @@ -50,7 +51,7 @@ func (KBSearchCommand) Execute(ctx registry.CommandContext, query string) { } } -func (KBSearchCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { +func (KBSearchCommand) AutoCompleteHandler(_ *worker.Context, data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { if value == "" { return nil } diff --git a/bot/command/impl/kb/send.go b/bot/command/impl/kb/send.go index 25acbdd6..c8ac39e5 100644 --- a/bot/command/impl/kb/send.go +++ b/bot/command/impl/kb/send.go @@ -9,6 +9,7 @@ import ( "github.com/TicketsBot-cloud/common/permission" "github.com/TicketsBot-cloud/common/sentry" "github.com/TicketsBot-cloud/gdl/objects/interaction" + "github.com/TicketsBot-cloud/worker" "github.com/TicketsBot-cloud/worker/bot/command" "github.com/TicketsBot-cloud/worker/bot/command/registry" "github.com/TicketsBot-cloud/worker/bot/customisation" @@ -65,7 +66,7 @@ func (KBSendCommand) Execute(ctx registry.CommandContext, articleIdStr string) { } } -func (KBSendCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { +func (KBSendCommand) AutoCompleteHandler(_ *worker.Context, data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { if value == "" { return nil } diff --git a/bot/command/impl/tags/tag.go b/bot/command/impl/tags/tag.go index baba6787..5ac8464b 100644 --- a/bot/command/impl/tags/tag.go +++ b/bot/command/impl/tags/tag.go @@ -12,6 +12,7 @@ import ( "github.com/TicketsBot-cloud/gdl/objects/channel/embed" "github.com/TicketsBot-cloud/gdl/objects/channel/message" "github.com/TicketsBot-cloud/gdl/objects/interaction" + "github.com/TicketsBot-cloud/worker" "github.com/TicketsBot-cloud/worker/bot/command" "github.com/TicketsBot-cloud/worker/bot/command/registry" "github.com/TicketsBot-cloud/worker/bot/customisation" @@ -130,7 +131,7 @@ func (TagCommand) Execute(ctx registry.CommandContext, tagId string) { } } -func (TagCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { +func (TagCommand) AutoCompleteHandler(_ *worker.Context, data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) // TODO: Propagate context defer cancel() diff --git a/bot/command/impl/tickets/close.go b/bot/command/impl/tickets/close.go index 8e6748a8..d8f51361 100644 --- a/bot/command/impl/tickets/close.go +++ b/bot/command/impl/tickets/close.go @@ -2,11 +2,14 @@ package tickets import ( "context" + "strings" "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/worker" "github.com/TicketsBot-cloud/worker/bot/command" "github.com/TicketsBot-cloud/worker/bot/command/registry" "github.com/TicketsBot-cloud/worker/bot/constants" @@ -38,10 +41,19 @@ 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) } -func (CloseCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { +func (CloseCommand) AutoCompleteHandler(worker *worker.Context, data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { var reasons []string var err error @@ -55,23 +67,21 @@ func (CloseCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoC ctx, cancel := utils.ContextTimeout(time.Millisecond * 1500) defer cancel() - // 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. - if len(value) == 0 { - var panelId *int - if ticket.Id != 0 { - panelId = ticket.PanelId - } + if ticket.Id == 0 || !memberCanClose(ctx, worker, data, ticket) { + // Not nil: Discord requires choices to be an array, and nil marshals to null + return []interaction.ApplicationCommandOptionChoice{} + } - reasons, err = dbclient.Client.CloseReason.GetTopCloseReasons(ctx, data.GuildId.Value, panelId, 10) - } else { - var panelId *int - if ticket.Id != 0 { - panelId = ticket.PanelId - } + if closeReasons, err := logic.GetPanelCloseReasons(ctx, ticket); err != nil { + sentry.Error(err) + } else if len(closeReasons.Reasons) > 0 { + return closeReasonChoices(closeReasons.Reasons, value) + } - reasons, err = dbclient.Client.CloseReason.GetTopCloseReasonsContaining(ctx, data.GuildId.Value, panelId, value, 10) + if len(value) == 0 { + reasons, err = dbclient.Client.CloseReason.GetTopCloseReasons(ctx, data.GuildId.Value, ticket.PanelId, 10) + } else { + reasons, err = dbclient.Client.CloseReason.GetTopCloseReasonsContaining(ctx, data.GuildId.Value, ticket.PanelId, value, 10) } if err != nil { @@ -86,3 +96,42 @@ func (CloseCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoC return choices } + +func memberCanClose(ctx context.Context, worker *worker.Context, data interaction.ApplicationCommandAutoCompleteInteraction, ticket database.Ticket) bool { + if data.Member == nil { + return false + } + + permissionLevel, err := permission.GetPermissionLevel(ctx, utils.ToRetriever(worker), *data.Member, data.GuildId.Value) + if err != nil { + sentry.Error(err) + return false + } + + canClose, err := utils.UserCanClose(ctx, permissionLevel, data.Member.User.Id, ticket) + if err != nil { + sentry.Error(err) + return false + } + + return canClose +} + +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..367a87c9 100644 --- a/bot/command/impl/tickets/closerequest.go +++ b/bot/command/impl/tickets/closerequest.go @@ -14,10 +14,12 @@ import ( "github.com/TicketsBot-cloud/gdl/objects/interaction" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/gdl/rest" + "github.com/TicketsBot-cloud/worker" "github.com/TicketsBot-cloud/worker/bot/command" "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 +65,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)) @@ -157,6 +169,6 @@ func (CloseRequestCommand) Execute(ctx registry.CommandContext, closeDelay *int, } // ReasonAutoCompleteHandler TODO: Make a utility function rather than call the Close handler directly -func (CloseRequestCommand) ReasonAutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { - return CloseCommand{}.AutoCompleteHandler(data, value) +func (CloseRequestCommand) ReasonAutoCompleteHandler(worker *worker.Context, data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { + return CloseCommand{}.AutoCompleteHandler(worker, data, value) } diff --git a/bot/command/impl/tickets/open.go b/bot/command/impl/tickets/open.go index 3601c6bb..5ffb55c3 100644 --- a/bot/command/impl/tickets/open.go +++ b/bot/command/impl/tickets/open.go @@ -10,6 +10,7 @@ import ( "github.com/TicketsBot-cloud/common/sentry" "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/interaction" + "github.com/TicketsBot-cloud/worker" "github.com/TicketsBot-cloud/worker/bot/button/handlers" "github.com/TicketsBot-cloud/worker/bot/command" cmdcontext "github.com/TicketsBot-cloud/worker/bot/command/context" @@ -59,7 +60,7 @@ func (OpenCommand) Execute(ctx *cmdcontext.SlashCommandContext, customId string) openWithPanel(ctx, panel) } -func (OpenCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { +func (OpenCommand) AutoCompleteHandler(_ *worker.Context, data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { if data.GuildId.Value == 0 { return nil } diff --git a/bot/command/impl/tickets/reopen.go b/bot/command/impl/tickets/reopen.go index f7bdcf75..3713d75f 100644 --- a/bot/command/impl/tickets/reopen.go +++ b/bot/command/impl/tickets/reopen.go @@ -8,6 +8,7 @@ import ( "github.com/TicketsBot-cloud/common/permission" "github.com/TicketsBot-cloud/common/sentry" "github.com/TicketsBot-cloud/gdl/objects/interaction" + "github.com/TicketsBot-cloud/worker" "github.com/TicketsBot-cloud/worker/bot/command" "github.com/TicketsBot-cloud/worker/bot/command/registry" "github.com/TicketsBot-cloud/worker/bot/dbclient" @@ -41,7 +42,7 @@ func (ReopenCommand) Execute(ctx registry.CommandContext, ticketId int) { logic.ReopenTicket(ctx, ctx, ticketId) } -func (ReopenCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { +func (ReopenCommand) AutoCompleteHandler(_ *worker.Context, data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { if data.GuildId.Value == 0 { return nil } diff --git a/bot/command/impl/tickets/switchpanel.go b/bot/command/impl/tickets/switchpanel.go index 38b65911..6f2ad805 100644 --- a/bot/command/impl/tickets/switchpanel.go +++ b/bot/command/impl/tickets/switchpanel.go @@ -14,6 +14,7 @@ import ( "github.com/TicketsBot-cloud/gdl/objects/interaction" "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" cmdcontext "github.com/TicketsBot-cloud/worker/bot/command/context" "github.com/TicketsBot-cloud/worker/bot/command/registry" @@ -332,7 +333,7 @@ func (SwitchPanelCommand) Execute(ctx *cmdcontext.SlashCommandContext, panelId i } } -func (SwitchPanelCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { +func (SwitchPanelCommand) AutoCompleteHandler(_ *worker.Context, data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { if data.GuildId.Value == 0 { return nil } 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/bot/utils/permissionutils.go b/bot/utils/permissionutils.go index b3470d71..aeb53c36 100644 --- a/bot/utils/permissionutils.go +++ b/bot/utils/permissionutils.go @@ -18,13 +18,22 @@ func CanClose(ctx context.Context, cmd registry.CommandContext, ticket database. return false } + canClose, err := UserCanClose(ctx, permissionLevel, cmd.UserId(), ticket) + if err != nil { + cmd.HandleError(err) + return false + } + + return canClose +} + +func UserCanClose(ctx context.Context, permissionLevel permission.PermissionLevel, userId uint64, ticket database.Ticket) (bool, error) { if permissionLevel == permission.Everyone { usersCanClose := true // default: allow users to close if ticket.PanelId != nil { p, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) if err != nil { - cmd.HandleError(err) - return false + return false, err } if p.PanelId != 0 { usersCanClose = p.UsersCanClose @@ -32,10 +41,10 @@ func CanClose(ctx context.Context, cmd registry.CommandContext, ticket database. } // If they are a normal user, don't let them close if users_can_close=false, or if they are not the opener - if !usersCanClose || cmd.UserId() != ticket.UserId { - return false + if !usersCanClose || userId != ticket.UserId { + return false, nil } } - return true + return true, nil } diff --git a/event/httplisten.go b/event/httplisten.go index 047f5ddd..b2048679 100644 --- a/event/httplisten.go +++ b/event/httplisten.go @@ -260,7 +260,7 @@ func interactionHandler(redis *redis.Client, cache *cache.PgCache) func(*gin.Con return } - choices := handler(interactionData, fmt.Sprintf("%v", focused.Value)) + choices := handler(worker, interactionData, fmt.Sprintf("%v", focused.Value)) res := interaction.NewApplicationCommandAutoCompleteResultResponse(choices) ctx.JSON(200, res) ctx.Writer.Flush() 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"