diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..395f1816 --- /dev/null +++ b/.env.example @@ -0,0 +1,132 @@ +# ============================================================================= +# Worker +# ============================================================================= +WORKER_MODE= # required (GATEWAY or INTERACTIONS) +HTTP_ADDR= # required +WORKER_DEBUG= +WORKER_JSON_LOGS= # default: false +WORKER_LOG_LEVEL= # default: info +WORKER_PREMIUM_ONLY= # default: false + +# ============================================================================= +# Discord +# ============================================================================= +WORKER_PUBLIC_TOKEN= # required +WORKER_PUBLIC_ID= # required +DISCORD_PROXY_URL= +DISCORD_REQUEST_TIMEOUT= # default: 15s +DISCORD_CALLBACK_TIMEOUT= # default: 2000ms +DISCORD_DEFER_HARD_TIMEOUT= # default: 2500ms +SHARDER_TOTAL= # default: 1 + +# ============================================================================= +# Bot +# ============================================================================= +WORKER_BOT_OWNER= +MONITORED_BOTS= +DASHBOARD_URL= # default: https://dashboard.tickets.bot +FRONTPAGE_URL= # default: https://tickets.bot +DOCS_URL= # default: https://docs.tickets.bot +VOTE_URL= # default: https://vote.tickets.bot +VOTE_SKU_ID= +POWEREDBY= # default: tickets.bot +ICON_URL= # default: https://tickets.bot/assets/img/logo.png +SUPPORT_SERVER_INVITE= # default: https://discord.gg/ticketsbot +INVITE_URL= # default: https://invite.tickets.bot + +# ============================================================================= +# Database +# ============================================================================= +DATABASE_HOST= # required +DATABASE_NAME= # required +DATABASE_USER= # required +DATABASE_PASSWORD= # required +DATABASE_THREADS= # required + +# ============================================================================= +# Cache +# ============================================================================= +CACHE_HOST= # required +CACHE_NAME= # required +CACHE_USER= # required +CACHE_PASSWORD= # required +CACHE_THREADS= # required + +# ============================================================================= +# Redis +# ============================================================================= +WORKER_REDIS_ADDR= # required +WORKER_REDIS_PASSWD= +WORKER_REDIS_THREADS= # required + +# ============================================================================= +# Kafka +# ============================================================================= +KAFKA_BROKERS= # required +KAFKA_EVENTS_TOPIC= # required +KAFKA_GOROUTINE_LIMIT= # default: 1000 + +# ============================================================================= +# Premium proxy +# ============================================================================= +WORKER_PROXY_URL= +WORKER_PROXY_KEY= + +# ============================================================================= +# Archiver +# ============================================================================= +WORKER_ARCHIVER_URL= +WORKER_ARCHIVER_AES_KEY= + +# ============================================================================= +# Web proxy +# ============================================================================= +WEB_PROXY_URL= +WEB_PROXY_AUTH_HEADER_NAME= +WEB_PROXY_AUTH_HEADER_VALUE= + +# ============================================================================= +# Integrations +# ============================================================================= +BLOXLINK_API_KEY= +SECURE_PROXY_URL= + +# ============================================================================= +# Experiments +# ============================================================================= +ENABLE_ALL_EXPERIMENTS= +EXPERIMENT_SERVERS= + +# ============================================================================= +# Observability +# ============================================================================= +PROMETHEUS_SERVER_ADDR= +WORKER_STATSD_ADDR= +WORKER_STATSD_PREFIX= +WORKER_SENTRY_DSN= +WORKER_SENTRY_SAMPLE_RATE= # default: 1.0 +WORKER_SENTRY_TRACING_ENABLED= +WORKER_SENTRY_TRACING_SAMPLE_RATE= +WORKER_CLOUD_PROFILER_ENABLED= # default: false +WORKER_CLOUD_PROFILER_PROJECT_ID= + +# ============================================================================= +# Emojis +# ============================================================================= +EMOJI_ID= +EMOJI_OPEN= +EMOJI_OPENTIME= +EMOJI_CLOSE= +EMOJI_CLOSETIME= +EMOJI_REASON= +EMOJI_SUBJECT= +EMOJI_TRANSCRIPT= +EMOJI_CLAIM= +EMOJI_PANEL= +EMOJI_RATING= +EMOJI_STAFF= +EMOJI_THREAD= +EMOJI_BULLETLINE= +EMOJI_PATREON= +EMOJI_DISCORD= +EMOJI_LOGO= diff --git a/bot/button/handlers/addadmin.go b/bot/button/handlers/addadmin.go index 13b31c2c..03cc7524 100644 --- a/bot/button/handlers/addadmin.go +++ b/bot/button/handlers/addadmin.go @@ -129,12 +129,6 @@ func (h *AddAdminHandler) Execute(ctx *context.ButtonContext) { e := utils.BuildEmbed(ctx, customisation.Green, i18n.TitleAddAdmin, i18n.MessageAddAdminSuccess, nil, mention) ctx.Edit(command.NewEphemeralEmbedMessageResponse(e)) - settings, err := ctx.Settings() - if err != nil { - ctx.HandleError(err) - return - } - // Get member and target name once for reuse in audit reasons member, err := ctx.Member() hasMember := err == nil @@ -156,8 +150,8 @@ func (h *AddAdminHandler) Execute(ctx *context.ButtonContext) { } } - // Add user / role to thread notification channel - if settings.TicketNotificationChannel != nil { + // Add user / role to each panel's thread notification channel + { auditReason := "Added admin member/role" if hasMember && targetName != "" { auditReason = fmt.Sprintf("Added admin %s (%s) by %s", mentionableType, targetName, member.User.Username) @@ -165,13 +159,25 @@ func (h *AddAdminHandler) Execute(ctx *context.ButtonContext) { auditReason = fmt.Sprintf("Added admin member/role by %s", member.User.Username) } - reasonCtx := request.WithAuditReason(ctx, auditReason) - _ = ctx.Worker().EditChannelPermissions(reasonCtx, *settings.TicketNotificationChannel, channel.PermissionOverwrite{ - Id: id, - Type: mentionableType.OverwriteType(), - Allow: permission.BuildPermissions(permission.ViewChannel, permission.UseApplicationCommands, permission.ReadMessageHistory), - Deny: 0, - }) + panels, _ := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) + seen := make(map[uint64]struct{}) + for _, p := range panels { + if p.TicketNotificationChannel == nil { + continue + } + chId := *p.TicketNotificationChannel + if _, already := seen[chId]; already { + continue + } + seen[chId] = struct{}{} + reasonCtx := request.WithAuditReason(ctx, auditReason) + _ = ctx.Worker().EditChannelPermissions(reasonCtx, chId, channel.PermissionOverwrite{ + Id: id, + Type: mentionableType.OverwriteType(), + Allow: permission.BuildPermissions(permission.ViewChannel, permission.UseApplicationCommands, permission.ReadMessageHistory), + Deny: 0, + }) + } } openTickets, err := dbclient.Client.Tickets.GetGuildOpenTicketsExcludeThreads(ctx, ctx.GuildId()) diff --git a/bot/button/handlers/addsupport.go b/bot/button/handlers/addsupport.go index 39b3d84d..f891ab47 100644 --- a/bot/button/handlers/addsupport.go +++ b/bot/button/handlers/addsupport.go @@ -130,12 +130,6 @@ func (h *AddSupportHandler) Execute(ctx *context.ButtonContext) { } func updateChannelPermissions(ctx cmdregistry.CommandContext, id uint64, mentionableType context.MentionableType) { - settings, err := ctx.Settings() - if err != nil { - ctx.HandleError(err) - return - } - // Get member and target name once for reuse in audit reasons member, err := ctx.Member() hasMember := err == nil @@ -157,24 +151,6 @@ func updateChannelPermissions(ctx cmdregistry.CommandContext, id uint64, mention } } - // Add user / role to thread notification channel - if settings.TicketNotificationChannel != nil { - auditReason := "Added support member/role" - if hasMember && targetName != "" { - auditReason = fmt.Sprintf("Added support %s (%s) by %s", mentionableType, targetName, member.User.Username) - } else if hasMember { - auditReason = fmt.Sprintf("Added support member/role by %s", member.User.Username) - } - - reasonCtx := request.WithAuditReason(ctx, auditReason) - _ = ctx.Worker().EditChannelPermissions(reasonCtx, *settings.TicketNotificationChannel, channel.PermissionOverwrite{ - Id: id, - Type: mentionableType.OverwriteType(), - Allow: permission.BuildPermissions(permission.ViewChannel, permission.UseApplicationCommands, permission.ReadMessageHistory), - Deny: 0, - }) - } - openTickets, err := dbclient.Client.Tickets.GetGuildOpenTicketsExcludeThreads(ctx, ctx.GuildId()) if err != nil { ctx.HandleError(err) @@ -187,6 +163,35 @@ func updateChannelPermissions(ctx cmdregistry.CommandContext, id uint64, mention return } + // Add user / role to each panel's thread notification channel + { + auditReason := "Added support member/role" + if hasMember && targetName != "" { + auditReason = fmt.Sprintf("Added support %s (%s) by %s", mentionableType, targetName, member.User.Username) + } else if hasMember { + auditReason = fmt.Sprintf("Added support member/role by %s", member.User.Username) + } + + seen := make(map[uint64]struct{}) + for _, p := range panels { + if p.TicketNotificationChannel == nil { + continue + } + chId := *p.TicketNotificationChannel + if _, already := seen[chId]; already { + continue + } + seen[chId] = struct{}{} + reasonCtx := request.WithAuditReason(ctx, auditReason) + _ = ctx.Worker().EditChannelPermissions(reasonCtx, chId, channel.PermissionOverwrite{ + Id: id, + Type: mentionableType.OverwriteType(), + Allow: permission.BuildPermissions(permission.ViewChannel, permission.UseApplicationCommands, permission.ReadMessageHistory), + Deny: 0, + }) + } + } + // Update permissions for existing tickets for _, ticket := range openTickets { if ticket.ChannelId == nil || ticket.IsThread { diff --git a/bot/button/handlers/admindebug/server/modals/panelsettings.go b/bot/button/handlers/admindebug/server/modals/panelsettings.go index b7a4b8cc..a0e90da5 100644 --- a/bot/button/handlers/admindebug/server/modals/panelsettings.go +++ b/bot/button/handlers/admindebug/server/modals/panelsettings.go @@ -216,12 +216,7 @@ func buildPanelSettings(ctx *context.ModalContext, selectedPanel *database.Panel } settings = append(settings, fmt.Sprintf("**Status:** `%s`", status)) - // Delete mentions - deleteMentions := "Disabled" - if selectedPanel.DeleteMentions { - deleteMentions = "Enabled" - } - settings = append(settings, fmt.Sprintf("**Delete Mentions:** `%s`", deleteMentions)) + settings = append(settings, fmt.Sprintf("**Mention Behaviour:** `%s`", selectedPanel.MentionBehaviour)) return strings.Join(settings, "\n") } diff --git a/bot/button/handlers/admindebug/server/modals/permissions.go b/bot/button/handlers/admindebug/server/modals/permissions.go index 3417e56c..9f49ba6a 100644 --- a/bot/button/handlers/admindebug/server/modals/permissions.go +++ b/bot/button/handlers/admindebug/server/modals/permissions.go @@ -88,13 +88,6 @@ func (h *AdminDebugServerPermissionsModalSubmitHandler) Execute(ctx *context.Mod return } - // Get guild and settings - settings, err := dbclient.Client.Settings.Get(ctx, guildId) - if err != nil { - ctx.HandleError(err) - return - } - panels, err := dbclient.Client.Panel.GetByGuild(ctx, guildId) if err != nil { ctx.HandleError(err) @@ -108,7 +101,7 @@ func (h *AdminDebugServerPermissionsModalSubmitHandler) Execute(ctx *context.Mod } // Process permission checks using shared logic - results, hasMissingPermissions := processPermissionChecks(selectedValues, worker, guildId, botMember, settings, panels) + results, hasMissingPermissions := processPermissionChecks(selectedValues, worker, guildId, botMember, panels) // Choose color based on whether permissions are missing colour := customisation.Green @@ -126,7 +119,7 @@ func (h *AdminDebugServerPermissionsModalSubmitHandler) Execute(ctx *context.Mod })) } -func processPermissionChecks(selectedValues []string, worker *w.Context, guildId uint64, botMember member.Member, settings database.Settings, panels []database.Panel) ([]string, bool) { +func processPermissionChecks(selectedValues []string, worker *w.Context, guildId uint64, botMember member.Member, panels []database.Panel) ([]string, bool) { // Server-wide permissions serverWidePermissions := append( []permission.Permission{ @@ -184,7 +177,7 @@ func processPermissionChecks(selectedValues []string, worker *w.Context, guildId } // Check permissions for this panel - panelResults, hasMissing := checkPanelPermissions(worker, guildId, botMember, *panel, settings) + panelResults, hasMissing := checkPanelPermissions(worker, guildId, botMember, *panel) results = append(results, fmt.Sprintf("**Panel: %s**\n%s", panel.Title, panelResults)) if hasMissing { hasMissingPermissions = true @@ -226,7 +219,7 @@ func checkServerWidePermissions(worker *w.Context, guildId uint64, botMember mem return result.String(), len(missing) > 0 } -func checkPanelPermissions(worker *w.Context, guildId uint64, botMember member.Member, panel database.Panel, settings database.Settings) (string, bool) { +func checkPanelPermissions(worker *w.Context, guildId uint64, botMember member.Member, panel database.Panel) (string, bool) { var results []string var hasMissingPermissions bool @@ -289,8 +282,7 @@ func checkPanelPermissions(worker *w.Context, guildId uint64, botMember member.M } // Check notification channel if using thread mode - if usesThreads && settings.TicketNotificationChannel != nil { - // Notification channel needs standard permissions + embed links + if usesThreads && panel.TicketNotificationChannel != nil { notificationPerms := append( []permission.Permission{ permission.EmbedLinks, @@ -298,7 +290,7 @@ func checkPanelPermissions(worker *w.Context, guildId uint64, botMember member.M }, logic.MinimalPermissions[:]..., ) - result, hasMissing := checkChannelPermissions(worker, *settings.TicketNotificationChannel, botMember, guildId, notificationPerms, "Notification Channel") + result, hasMissing := checkChannelPermissions(worker, *panel.TicketNotificationChannel, botMember, guildId, notificationPerms, "Notification Channel") results = append(results, result) if hasMissing { hasMissingPermissions = true diff --git a/bot/button/handlers/admindebug/server/recache.go b/bot/button/handlers/admindebug/server/recache.go index 1f1f2a74..9bf99c4c 100644 --- a/bot/button/handlers/admindebug/server/recache.go +++ b/bot/button/handlers/admindebug/server/recache.go @@ -35,7 +35,7 @@ func (h *AdminDebugServerRecacheHandler) Properties() registry.Properties { } func (h *AdminDebugServerRecacheHandler) Execute(ctx *context.ButtonContext) { - if !utils.IsBotHelper(ctx.UserId()) { + if !utils.IsBotHelper(ctx, ctx.UserId()) { ctx.ReplyRaw(customisation.Red, "Error", "You do not have permission to use this button.") } diff --git a/bot/button/handlers/claim.go b/bot/button/handlers/claim.go index 20323404..cef0f1d9 100644 --- a/bot/button/handlers/claim.go +++ b/bot/button/handlers/claim.go @@ -1,6 +1,7 @@ package handlers import ( + "errors" "fmt" "github.com/TicketsBot-cloud/common/permission" @@ -56,6 +57,12 @@ func (h *ClaimHandler) Execute(ctx *context.ButtonContext) { } if err := logic.ClaimTicket(ctx.Context, ctx, ticket, ctx.UserId()); err != nil { + var alreadyClaimed logic.AlreadyClaimedError + if errors.As(err, &alreadyClaimed) { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageAlreadyClaimed, fmt.Sprintf("<@%d>", alreadyClaimed.ClaimerId)) + return + } + ctx.HandleError(err) return } diff --git a/bot/button/handlers/close.go b/bot/button/handlers/close.go index ac6ac320..1dc43a64 100644 --- a/bot/button/handlers/close.go +++ b/bot/button/handlers/close.go @@ -49,10 +49,16 @@ func (h *CloseHandler) Execute(ctx *cmdcontext.ButtonContext) { return } - closeConfirmation, err := dbclient.Client.CloseConfirmation.Get(ctx, ctx.GuildId()) - if err != nil { - ctx.HandleError(err) - return + closeConfirmation := true // default: show close confirmation + if ticket.PanelId != nil { + p, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) + if err != nil { + ctx.HandleError(err) + return + } + if p.PanelId != 0 { + closeConfirmation = p.CloseConfirmation + } } if closeConfirmation { diff --git a/bot/button/handlers/editclosereasonsubmit.go b/bot/button/handlers/editclosereasonsubmit.go index 3c72e299..da10385f 100644 --- a/bot/button/handlers/editclosereasonsubmit.go +++ b/bot/button/handlers/editclosereasonsubmit.go @@ -99,10 +99,17 @@ func (h *EditCloseReasonSubmitHandler) Execute(ctx *context.ModalContext) { return } - settings, err := dbclient.Client.Settings.Get(ctx.Context, guildId) - if err != nil { - ctx.HandleError(err) - return + var storeTranscripts, feedbackEnabled bool + if ticket.PanelId != nil { + p, err := dbclient.Client.Panel.GetById(ctx.Context, *ticket.PanelId) + if err != nil { + ctx.HandleError(err) + return + } + if p.PanelId != 0 { + storeTranscripts = p.StoreTranscripts + feedbackEnabled = p.FeedbackEnabled + } } var closedBy uint64 @@ -123,11 +130,11 @@ func (h *EditCloseReasonSubmitHandler) Execute(ctx *context.ModalContext) { ctx.Ack() - if err := logic.EditGuildArchiveMessageIfExists(ctx.Context, ctx.Worker(), ticket, settings, hasFeedback, closedBy, &reason, rating); err != nil { + if err := logic.EditGuildArchiveMessageIfExists(ctx.Context, ctx.Worker(), ticket, storeTranscripts, hasFeedback, closedBy, &reason, rating); err != nil { ctx.HandleError(err) } - if err := logic.EditDMMessageIfExists(ctx.Context, ctx.Worker(), ticket, settings, closedBy, &reason, rating); err != nil { + if err := logic.EditDMMessageIfExists(ctx.Context, ctx.Worker(), ticket, storeTranscripts, feedbackEnabled, closedBy, &reason, rating); err != nil { ctx.HandleError(err) } } diff --git a/bot/button/handlers/exitsurveysubmit.go b/bot/button/handlers/exitsurveysubmit.go index 4eea7692..fdde5d0b 100644 --- a/bot/button/handlers/exitsurveysubmit.go +++ b/bot/button/handlers/exitsurveysubmit.go @@ -81,17 +81,6 @@ func (h *ExitSurveySubmitHandler) Execute(cmd *cmdcontext.ModalContext) { return } - feedbackEnabled, err := dbclient.Client.FeedbackEnabled.Get(ctx, guildId) - if err != nil { - cmd.HandleError(err) - return - } - - if !feedbackEnabled { - cmd.Reply(customisation.Red, i18n.Error, i18n.MessageFeedbackDisabled) - return - } - if ticket.PanelId == nil { cmd.ReplyRaw(customisation.Red, "Error", "The survey is no longer available for this ticket.") // TODO: i18n return @@ -108,6 +97,11 @@ func (h *ExitSurveySubmitHandler) Execute(cmd *cmdcontext.ModalContext) { return } + if !panel.FeedbackEnabled { + cmd.Reply(customisation.Red, i18n.Error, i18n.MessageFeedbackDisabled) + return + } + if panel.ExitSurveyFormId == nil { cmd.ReplyRaw(customisation.Red, "Error", "The survey is no longer available for this ticket.") // TODO: i18n return @@ -161,10 +155,15 @@ func (h *ExitSurveySubmitHandler) Execute(cmd *cmdcontext.ModalContext) { } func addViewFeedbackButton(ctx context.Context, cmd *cmdcontext.ModalContext, ticket database.Ticket) error { - // Get archive message - settings, err := cmd.Settings() - if err != nil { - return err + var storeTranscripts bool + if ticket.PanelId != nil { + p, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) + if err != nil { + return err + } + if p.PanelId != 0 { + storeTranscripts = p.StoreTranscripts + } } closeMetadata, ok, err := dbclient.Client.CloseReason.Get(ctx, ticket.GuildId, ticket.Id) @@ -191,5 +190,5 @@ func addViewFeedbackButton(ctx context.Context, cmd *cmdcontext.ModalContext, ti return fmt.Errorf("exit survey was completed, but no rating was found (%d:%d)", ticket.GuildId, ticket.Id) } - return logic.EditGuildArchiveMessageIfExists(ctx, cmd.Worker(), ticket, settings, true, closedBy, reason, &rating) + return logic.EditGuildArchiveMessageIfExists(ctx, cmd.Worker(), ticket, storeTranscripts, true, closedBy, reason, &rating) } diff --git a/bot/button/handlers/form.go b/bot/button/handlers/form.go index 135a3e83..7582125f 100644 --- a/bot/button/handlers/form.go +++ b/bot/button/handlers/form.go @@ -75,7 +75,7 @@ func (h *FormHandler) Execute(ctx *context.ModalContext) { } ctx.Defer() - _, _ = logic.OpenTicket(ctx.Context, ctx, &panel, panel.Title, formAnswers, outOfHoursTitle, outOfHoursWarning, outOfHoursColour) + _, _ = logic.OpenTicket(ctx.Context, ctx, &panel, panel.Title, formAnswers, outOfHoursTitle, outOfHoursWarning, outOfHoursColour, database.TicketSourcePanel) return } diff --git a/bot/button/handlers/formapiconfig.go b/bot/button/handlers/formapiconfig.go new file mode 100644 index 00000000..0a435f1b --- /dev/null +++ b/bot/button/handlers/formapiconfig.go @@ -0,0 +1,252 @@ +package handlers + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "sync" + "time" + + "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/dbclient" + "github.com/TicketsBot-cloud/worker/bot/integrations" +) + +type apiOption struct { + Label string `json:"label"` + Value string `json:"value"` + Description *string `json:"description,omitempty"` +} + +func autoRequestBody(placeholders map[string]func() string) map[string]any { + body := make(map[string]any, len(placeholders)) + for name, resolve := range placeholders { + body[name] = resolve() + } + + if roles, ok := body["user_roles"].(string); ok { + if roles == "" { + body["user_roles"] = []string{} + } else { + body["user_roles"] = strings.Split(roles, ",") + } + } + + return body +} + +type apiOptionsCacheEntry struct { + expiresAt time.Time + options []database.FormInputOption +} + +var apiOptionsCache = struct { + sync.Mutex + items map[string]apiOptionsCacheEntry +}{ + items: make(map[string]apiOptionsCacheEntry), +} + +func FetchApiOptions( + cmd registry.CommandContext, + form database.Form, + panel database.Panel, + inputs []database.FormInput, + inputOptions map[int][]database.FormInputOption, +) { + ctx := context.Context(cmd) + + configs, err := dbclient.Client.FormInputApiConfig.GetByFormId(ctx, form.Id) + if err != nil { + sentry.Error(err) + return + } + + if len(configs) == 0 { + return + } + + configByInputId := make(map[int]database.FormInputApiConfig, len(configs)) + for _, cfg := range configs { + configByInputId[cfg.FormInputId] = cfg + } + + placeholders := formApiPlaceholders(ctx, cmd, panel) + + var lock sync.Mutex + var wg sync.WaitGroup + + for _, input := range inputs { + cfg, ok := configByInputId[input.Id] + if !ok { + continue + } + + wg.Add(1) + go func() { + defer wg.Done() + + options, err := fetchOptionsFromApi(ctx, cfg, input, placeholders) + if err != nil { + sentry.Error(err) + options = fallbackOptions(cfg) + } + + if len(options) == 0 { + options = fallbackOptions(cfg) + } + + lock.Lock() + inputOptions[input.Id] = options + lock.Unlock() + }() + } + + wg.Wait() +} + +func fetchOptionsFromApi( + ctx context.Context, + cfg database.FormInputApiConfig, + input database.FormInput, + placeholders map[string]func() string, +) ([]database.FormInputOption, error) { + url := integrations.Substitute(cfg.EndpointUrl, integrations.ScopeUrl, placeholders) + + headers, err := dbclient.Client.FormInputApiHeaders.GetByApiConfig(ctx, cfg.Id) + if err != nil { + return nil, err + } + + headerMap := make(map[string]string) + for _, h := range headers { + if integrations.IsHeaderBlacklisted(h.HeaderName) { + continue + } + + headerMap[h.HeaderName] = integrations.Substitute(h.HeaderValue, integrations.ScopeHeader, placeholders) + } + + var body any + var bodyJson []byte + if cfg.Method == http.MethodPost { + if cfg.BodyTemplate != nil && strings.TrimSpace(*cfg.BodyTemplate) != "" { + substituted := integrations.Substitute(*cfg.BodyTemplate, integrations.ScopeBody, placeholders) + if !json.Valid([]byte(substituted)) { + return nil, fmt.Errorf("body template for form input %d did not produce valid JSON", input.Id) + } + + bodyJson = []byte(substituted) + body = json.RawMessage(substituted) + } else { + auto := autoRequestBody(placeholders) + + bodyJson, err = json.Marshal(auto) + if err != nil { + return nil, err + } + + body = auto + } + } + + cacheKey := apiOptionsCacheKey(cfg.Id, url, headerMap, bodyJson) + if cfg.CacheDurationSeconds != nil && *cfg.CacheDurationSeconds > 0 { + apiOptionsCache.Lock() + entry, ok := apiOptionsCache.items[cacheKey] + if ok && time.Now().Before(entry.expiresAt) { + options := cloneFormInputOptions(entry.options) + apiOptionsCache.Unlock() + return options, nil + } + if ok { + delete(apiOptionsCache.items, cacheKey) + } + apiOptionsCache.Unlock() + } + + res, err := integrations.SecureProxy.DoRequest(ctx, cfg.Method, url, headerMap, body) + if err != nil { + return nil, err + } + + var apiOptions []apiOption + if err := json.Unmarshal(res, &apiOptions); err != nil { + return nil, err + } + + options := make([]database.FormInputOption, 0, len(apiOptions)) + for i, opt := range apiOptions { + if opt.Label == "" || opt.Value == "" { + continue + } + + options = append(options, database.FormInputOption{ + FormInputId: cfg.FormInputId, + Position: i + 1, + Label: opt.Label, + Value: opt.Value, + Description: opt.Description, + }) + } + + if cfg.CacheDurationSeconds != nil && *cfg.CacheDurationSeconds > 0 { + apiOptionsCache.Lock() + apiOptionsCache.items[cacheKey] = apiOptionsCacheEntry{ + expiresAt: time.Now().Add(time.Duration(*cfg.CacheDurationSeconds) * time.Second), + options: cloneFormInputOptions(options), + } + apiOptionsCache.Unlock() + } + + return options, nil +} + +func apiOptionsCacheKey(configId int, url string, headers map[string]string, body []byte) string { + names := make([]string, 0, len(headers)) + for name := range headers { + names = append(names, name) + } + sort.Strings(names) + + hash := sha256.New() + hash.Write([]byte(url)) + for _, name := range names { + hash.Write([]byte{0}) + hash.Write([]byte(name)) + hash.Write([]byte{0}) + hash.Write([]byte(headers[name])) + } + hash.Write([]byte{0}) + hash.Write(body) + + return fmt.Sprintf("%d:%x", configId, hash.Sum(nil)) +} + +func cloneFormInputOptions(options []database.FormInputOption) []database.FormInputOption { + cloned := make([]database.FormInputOption, len(options)) + copy(cloned, options) + return cloned +} + +func fallbackOptions(cfg database.FormInputApiConfig) []database.FormInputOption { + message := "No options available" + if cfg.NoOptionsMessage != nil && *cfg.NoOptionsMessage != "" { + message = *cfg.NoOptionsMessage + } + + return []database.FormInputOption{ + { + FormInputId: cfg.FormInputId, + Position: 1, + Label: message, + Value: "_no_options", + }, + } +} diff --git a/bot/button/handlers/formapiplaceholders.go b/bot/button/handlers/formapiplaceholders.go new file mode 100644 index 00000000..0cd84dde --- /dev/null +++ b/bot/button/handlers/formapiplaceholders.go @@ -0,0 +1,77 @@ +package handlers + +import ( + "context" + "strconv" + "strings" + "sync" + + permcache "github.com/TicketsBot-cloud/common/permission" + "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/objects/member" + "github.com/TicketsBot-cloud/gdl/objects/user" + "github.com/TicketsBot-cloud/worker/bot/command/registry" +) + +func formApiPlaceholders( + ctx context.Context, + cmd registry.CommandContext, + panel database.Panel, +) map[string]func() string { + fetchUser := sync.OnceValue(func() user.User { + u, _ := cmd.User() + return u + }) + + fetchMember := sync.OnceValue(func() member.Member { + m, _ := cmd.Member() + return m + }) + + return map[string]func() string{ + "user_id": sync.OnceValue(func() string { return strconv.FormatUint(cmd.UserId(), 10) }), + "guild_id": sync.OnceValue(func() string { return strconv.FormatUint(cmd.GuildId(), 10) }), + + "username": sync.OnceValue(func() string { return fetchUser().Username }), + "user_nickname": sync.OnceValue(func() string { + if nick := fetchMember().Nick; nick != "" { + return nick + } + + return fetchUser().Username + }), + "user_roles": sync.OnceValue(func() string { + roles := fetchMember().Roles + formatted := make([]string, len(roles)) + for i, roleId := range roles { + formatted[i] = strconv.FormatUint(roleId, 10) + } + + return strings.Join(formatted, ",") + }), + "user_permission_level": sync.OnceValue(func() string { + level, err := cmd.UserPermissionLevel(ctx) + if err != nil { + return "" + } + + switch level { + case permcache.Admin: + return "admin" + case permcache.Support: + return "support" + default: + return "everyone" + } + }), + "user_locale": sync.OnceValue(func() string { + if ictx, ok := cmd.(registry.InteractionContext); ok { + return ictx.InteractionMetadata().Locale + } + + return "" + }), + + "panel_title": sync.OnceValue(func() string { return panel.Title }), + } +} diff --git a/bot/button/handlers/kb.go b/bot/button/handlers/kb.go new file mode 100644 index 00000000..f242375f --- /dev/null +++ b/bot/button/handlers/kb.go @@ -0,0 +1,247 @@ +package handlers + +import ( + "strconv" + "strings" + "time" + + "github.com/TicketsBot-cloud/worker/bot/button/registry" + "github.com/TicketsBot-cloud/worker/bot/button/registry/matcher" + "github.com/TicketsBot-cloud/worker/bot/command" + "github.com/TicketsBot-cloud/worker/bot/command/context" + "github.com/TicketsBot-cloud/worker/bot/command/impl/kb" + "github.com/TicketsBot-cloud/worker/bot/customisation" + "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/i18n" +) + +// KBCategorySelectHandler handles the category string select on the knowledge base +// landing card. It renders the chosen category's article list in place. +type KBCategorySelectHandler struct{} + +func (h *KBCategorySelectHandler) Matcher() matcher.Matcher { + return matcher.NewFuncMatcher(func(customId string) bool { + return strings.HasPrefix(customId, "kb:") + }) +} + +func (h *KBCategorySelectHandler) Properties() registry.Properties { + return registry.Properties{ + Flags: registry.SumFlags(registry.GuildAllowed), + Timeout: time.Second * 3, + } +} + +func (h *KBCategorySelectHandler) Execute(ctx *context.SelectMenuContext) { + if len(ctx.InteractionData.Values) == 0 { + return + } + + categoryId, err := strconv.Atoi(ctx.InteractionData.Values[0]) + if err != nil { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageInvalidArgument) + return + } + + category, ok, err := dbclient.Client.KBCategories.Get(ctx, categoryId) + if err != nil { + ctx.HandleError(err) + return + } + + if !ok || category.GuildId != ctx.GuildId() { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageInvalidArgument) + return + } + + articles, err := dbclient.Client.KBArticles.GetByCategory(ctx, ctx.GuildId(), categoryId) + if err != nil { + ctx.HandleError(err) + return + } + + ctx.Edit(command.NewEphemeralMessageResponseWithComponents( + kb.BuildArticleList(ctx, category.Name, articles, kb.SourceBrowse), + )) +} + +// KBNavigationHandler handles the knowledge base navigation buttons: opening an +// article (kb:read), returning to the category picker (kb:back / kb:home). These are +// self-help actions, so any guild member may use them. +type KBNavigationHandler struct{} + +func (h *KBNavigationHandler) Matcher() matcher.Matcher { + return matcher.NewFuncMatcher(func(customId string) bool { + return strings.HasPrefix(customId, "kb:") + }) +} + +func (h *KBNavigationHandler) Properties() registry.Properties { + return registry.Properties{ + Flags: registry.SumFlags(registry.GuildAllowed), + Timeout: time.Second * 3, + } +} + +func (h *KBNavigationHandler) Execute(ctx *context.ButtonContext) { + segments := strings.Split(ctx.InteractionData.CustomId, ":") + if len(segments) < 2 { + return + } + + switch segments[1] { + case "read": + h.handleRead(ctx, segments) + case "back": + h.handleBack(ctx, segments) + case "home": + h.handleHome(ctx) + case "helpful": + h.handleHelpful(ctx, segments) + } +} + +func (h *KBNavigationHandler) handleHelpful(ctx *context.ButtonContext, segments []string) { + // kb:helpful:: + if len(segments) < 4 { + return + } + + articleId, err := strconv.Atoi(segments[2]) + if err != nil { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageInvalidArgument) + return + } + + article, ok, err := dbclient.Client.KBArticles.Get(ctx, articleId) + if err != nil { + ctx.HandleError(err) + return + } + + // Article IDs are global, so verify the article belongs to this guild and is + // published before recording feedback against it. + if !ok || article.GuildId != ctx.GuildId() || !article.Published { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageKbArticleNotFound) + return + } + + src := segments[3] + + // A deflection source records which panel drove the feedback, so deflection + // effectiveness can be attributed to the panel that suggested the article. + var panelId *int + if panelCustomId, isDeflection := kb.PanelCustomIdFromSource(src); isDeflection { + panel, panelOk, err := dbclient.Client.Panel.GetByCustomId(ctx, ctx.GuildId(), panelCustomId) + if err != nil { + ctx.HandleError(err) + return + } + + if panelOk && panel.GuildId == ctx.GuildId() { + panelId = &panel.PanelId + } + } + + if err := dbclient.Client.KBArticleFeedback.Set(ctx, ctx.GuildId(), articleId, panelId, ctx.UserId(), true); err != nil { + ctx.HandleError(err) + return + } + + // A public /kb send message is shared, so editing it would change what every viewer + // sees; acknowledge privately instead. Ephemeral views belong to the one user, so + // confirm the vote in place by disabling the button. + if src == kb.SourcePublic { + ctx.Reply(customisation.Green, i18n.MessageKbFeedbackThanks, i18n.MessageKbFeedbackThanksBody) + return + } + + ctx.Edit(command.NewEphemeralMessageResponseWithComponents( + kb.BuildArticleView(ctx, article, src, true), + )) +} + +func (h *KBNavigationHandler) handleBack(ctx *context.ButtonContext, segments []string) { + // kb:back:. A deflection source returns to that panel's suggestions; anything + // else returns to the category picker. + if len(segments) >= 3 { + if panelCustomId, ok := kb.PanelCustomIdFromSource(segments[2]); ok { + h.handleDeflectionBack(ctx, panelCustomId) + return + } + } + + h.handleHome(ctx) +} + +func (h *KBNavigationHandler) handleDeflectionBack(ctx *context.ButtonContext, panelCustomId string) { + panel, ok, err := dbclient.Client.Panel.GetByCustomId(ctx, ctx.GuildId(), panelCustomId) + if err != nil { + ctx.HandleError(err) + return + } + + // If the panel or its suggestions are gone, fall back to the category picker rather + // than stranding the user. + if !ok || panel.GuildId != ctx.GuildId() { + h.handleHome(ctx) + return + } + + articles, err := collectPanelDeflectionArticles(ctx, ctx.GuildId(), panel.PanelId) + if err != nil { + ctx.HandleError(err) + return + } + + if len(articles) == 0 { + h.handleHome(ctx) + return + } + + ctx.Edit(command.NewEphemeralMessageResponseWithComponents( + kb.BuildDeflectionCard(ctx, panel, articles), + )) +} + +func (h *KBNavigationHandler) handleRead(ctx *context.ButtonContext, segments []string) { + // kb:read:: + if len(segments) < 4 { + return + } + + articleId, err := strconv.Atoi(segments[2]) + if err != nil { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageInvalidArgument) + return + } + + article, ok, err := dbclient.Client.KBArticles.Get(ctx, articleId) + if err != nil { + ctx.HandleError(err) + return + } + + // Article IDs are global, so verify the article belongs to this guild and is + // published before rendering it. + if !ok || article.GuildId != ctx.GuildId() || !article.Published { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageKbArticleNotFound) + return + } + + ctx.Edit(command.NewEphemeralMessageResponseWithComponents( + kb.BuildArticleView(ctx, article, segments[3], false), + )) +} + +func (h *KBNavigationHandler) handleHome(ctx *context.ButtonContext) { + categories, err := dbclient.Client.KBCategories.GetByGuild(ctx, ctx.GuildId()) + if err != nil { + ctx.HandleError(err) + return + } + + ctx.Edit(command.NewEphemeralMessageResponseWithComponents( + kb.BuildCategoryPicker(ctx, categories), + )) +} diff --git a/bot/button/handlers/kbdeflection.go b/bot/button/handlers/kbdeflection.go new file mode 100644 index 00000000..023a9c72 --- /dev/null +++ b/bot/button/handlers/kbdeflection.go @@ -0,0 +1,126 @@ +package handlers + +import ( + stdcontext "context" + "strings" + + "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/worker/bot/button/registry" + "github.com/TicketsBot-cloud/worker/bot/button/registry/matcher" + "github.com/TicketsBot-cloud/worker/bot/command" + "github.com/TicketsBot-cloud/worker/bot/command/context" + "github.com/TicketsBot-cloud/worker/bot/command/impl/kb" + cmdregistry "github.com/TicketsBot-cloud/worker/bot/command/registry" + "github.com/TicketsBot-cloud/worker/bot/constants" + "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/bot/logic" +) + +// collectPanelDeflectionArticles returns the published knowledge base articles linked +// to a panel, deduplicated by article id. It returns an empty slice when the panel has +// no linked categories or none of them contain published articles. GetByCategory +// already filters to the guild and to published articles. +func collectPanelDeflectionArticles(ctx stdcontext.Context, guildId uint64, panelId int) ([]database.KBArticle, error) { + categoryIds, err := dbclient.Client.PanelKBCategories.GetByPanel(ctx, panelId) + if err != nil { + return nil, err + } + + if len(categoryIds) == 0 { + return nil, nil + } + + seen := make(map[int]struct{}) + var articles []database.KBArticle + for _, categoryId := range categoryIds { + categoryArticles, err := dbclient.Client.KBArticles.GetByCategory(ctx, guildId, categoryId) + if err != nil { + return nil, err + } + + for _, article := range categoryArticles { + if _, ok := seen[article.Id]; ok { + continue + } + + seen[article.Id] = struct{}{} + articles = append(articles, article) + } + } + + return articles, nil +} + +// tryPanelDeflection sends the deflection card and returns true if the panel has +// linked knowledge base articles to suggest. When it returns (false, nil) the caller +// should open the ticket as normal. It never edits the panel message: the card is sent +// as a fresh ephemeral response. +func tryPanelDeflection(ctx cmdregistry.CommandContext, panel database.Panel) (bool, error) { + articles, err := collectPanelDeflectionArticles(ctx, ctx.GuildId(), panel.PanelId) + if err != nil { + return false, err + } + + if len(articles) == 0 { + return false, nil + } + + if _, err := ctx.ReplyWith(command.NewEphemeralMessageResponseWithComponents( + kb.BuildDeflectionCard(ctx, panel, articles), + )); err != nil { + return false, err + } + + return true, nil +} + +// KBCreateTicketHandler handles the "Create ticket anyway" button on the deflection +// card. It re-validates panel access and opens the ticket via the same path as a +// normal panel click, so a panel with a form still shows its form. +type KBCreateTicketHandler struct{} + +func (h *KBCreateTicketHandler) Matcher() matcher.Matcher { + return matcher.NewFuncMatcher(func(customId string) bool { + return strings.HasPrefix(customId, "kbopen:") + }) +} + +func (h *KBCreateTicketHandler) Properties() registry.Properties { + // Mirror PanelHandler: opening a ticket needs the long timeout and CanEdit. + return registry.Properties{ + Flags: registry.SumFlags(registry.GuildAllowed, registry.CanEdit), + Timeout: constants.TimeoutOpenTicket, + } +} + +func (h *KBCreateTicketHandler) Execute(ctx *context.ButtonContext) { + // Panel custom ids may themselves contain colons, so split on the first only. + parts := strings.SplitN(ctx.InteractionData.CustomId, ":", 2) + if len(parts) != 2 || parts[1] == "" { + return + } + + panel, ok, err := dbclient.Client.Panel.GetByCustomId(ctx, ctx.GuildId(), parts[1]) + if err != nil { + ctx.HandleError(err) + return + } + + if !ok || panel.GuildId != ctx.GuildId() { + return + } + + // Re-validate panel access: never trust the button alone. + canProceed, outOfHoursTitle, outOfHoursWarning, outOfHoursColour, err := logic.ValidatePanelAccess(ctx, panel) + if err != nil { + ctx.HandleError(err) + return + } + + if !canProceed { + return + } + + // Deliberately skip the KB deflection check here so the ticket always opens. + openPanelOrForm(ctx, panel, outOfHoursTitle, outOfHoursWarning, outOfHoursColour) +} diff --git a/bot/button/handlers/multipanel.go b/bot/button/handlers/multipanel.go index da04b91a..edf3a9cf 100644 --- a/bot/button/handlers/multipanel.go +++ b/bot/button/handlers/multipanel.go @@ -4,6 +4,7 @@ import ( "errors" "github.com/TicketsBot-cloud/common/sentry" + "github.com/TicketsBot-cloud/database" "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" @@ -57,8 +58,17 @@ func (h *MultiPanelHandler) Execute(ctx *context.SelectMenuContext) { return } + // If the panel has linked knowledge base categories with published articles, + // show the deflection card first instead of opening immediately. + if deflected, err := tryPanelDeflection(ctx, panel); err != nil { + ctx.HandleError(err) + return + } else if deflected { + return + } + if panel.FormId == nil { - _, _ = logic.OpenTicket(ctx.Context, ctx, &panel, panel.Title, nil, outOfHoursTitle, outOfHoursWarning, outOfHoursColour) + _, _ = logic.OpenTicket(ctx.Context, ctx, &panel, panel.Title, nil, outOfHoursTitle, outOfHoursWarning, outOfHoursColour, database.TicketSourcePanel) } else { form, ok, err := dbclient.Client.Forms.Get(ctx, *panel.FormId) if err != nil { @@ -83,8 +93,10 @@ func (h *MultiPanelHandler) Execute(ctx *context.SelectMenuContext) { return } + FetchApiOptions(ctx, form, panel, inputs, inputOptions) + if len(inputs) == 0 { // Don't open a blank form - _, _ = logic.OpenTicket(ctx.Context, ctx, &panel, panel.Title, nil, outOfHoursTitle, outOfHoursWarning, outOfHoursColour) + _, _ = logic.OpenTicket(ctx.Context, ctx, &panel, panel.Title, nil, outOfHoursTitle, outOfHoursWarning, outOfHoursColour, database.TicketSourcePanel) } else { modal := buildForm(panel, form, inputs, inputOptions) ctx.Modal(modal) diff --git a/bot/button/handlers/opensurvey.go b/bot/button/handlers/opensurvey.go index 33e1ed8c..3e9c2a78 100644 --- a/bot/button/handlers/opensurvey.go +++ b/bot/button/handlers/opensurvey.go @@ -77,17 +77,6 @@ func (h *OpenSurveyHandler) Execute(ctx *context.ButtonContext) { return } - feedbackEnabled, err := dbclient.Client.FeedbackEnabled.Get(ctx, guildId) - if err != nil { - ctx.HandleError(err) - return - } - - if !feedbackEnabled { - ctx.Reply(customisation.Red, i18n.Error, i18n.MessageFeedbackDisabled) - return - } - if ticket.PanelId == nil { ctx.ReplyRaw(customisation.Red, "Error", "The survey is no longer available for this ticket.") // TODO: i18n return @@ -104,6 +93,11 @@ func (h *OpenSurveyHandler) Execute(ctx *context.ButtonContext) { return } + if !panel.FeedbackEnabled { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageFeedbackDisabled) + return + } + if panel.ExitSurveyFormId == nil { ctx.ReplyRaw(customisation.Red, "Error", "The survey is no longer available for this ticket.") // TODO: i18n return @@ -132,6 +126,8 @@ func (h *OpenSurveyHandler) Execute(ctx *context.ButtonContext) { return } + FetchApiOptions(ctx, form, panel, formInputs, inputOptions) + ctx.Modal(button.ResponseModal{ Data: interaction.ModalResponseData{ CustomId: fmt.Sprintf("exit-survey-%d-%d", guildId, ticketId), diff --git a/bot/button/handlers/panel.go b/bot/button/handlers/panel.go index 2b1ede45..75431c54 100644 --- a/bot/button/handlers/panel.go +++ b/bot/button/handlers/panel.go @@ -38,59 +38,79 @@ func (h *PanelHandler) Execute(ctx *context.ButtonContext) { return } - if ok { - // TODO: Log this - if panel.GuildId != ctx.GuildId() { - return - } + if !ok { + return + } - // Validate panel access - canProceed, outOfHoursTitle, outOfHoursWarning, outOfHoursColour, err := logic.ValidatePanelAccess(ctx, panel) - if err != nil { - ctx.HandleError(err) - return - } + // TODO: Log this + if panel.GuildId != ctx.GuildId() { + return + } - if !canProceed { - return - } + // Validate panel access + canProceed, outOfHoursTitle, outOfHoursWarning, outOfHoursColour, err := logic.ValidatePanelAccess(ctx, panel) + if err != nil { + ctx.HandleError(err) + return + } - if panel.FormId == nil { - _, _ = logic.OpenTicket(ctx.Context, ctx, &panel, panel.Title, nil, outOfHoursTitle, outOfHoursWarning, outOfHoursColour) - } else { - form, ok, err := dbclient.Client.Forms.Get(ctx, *panel.FormId) - if err != nil { - ctx.HandleError(err) - return - } + if !canProceed { + return + } - if !ok { - ctx.HandleError(errors.New("Form not found")) - return - } + // If the panel has linked knowledge base categories with published articles, show + // the deflection card first instead of opening immediately. + if deflected, err := tryPanelDeflection(ctx, panel); err != nil { + ctx.HandleError(err) + return + } else if deflected { + return + } - inputs, err := dbclient.Client.FormInput.GetInputs(ctx, form.Id) - if err != nil { - ctx.HandleError(err) - return - } + openPanelOrForm(ctx, panel, outOfHoursTitle, outOfHoursWarning, outOfHoursColour) +} - inputOptions, err := dbclient.Client.FormInputOption.GetOptionsByForm(ctx, form.Id) - if err != nil { - ctx.HandleError(err) - return - } +// openPanelOrForm either opens a ticket directly or, if the panel has a form, +// shows the form modal (opening directly when the form has no inputs). Shared by the +// normal panel click and the "Create ticket anyway" deflection button so both entry +// points behave identically. +func openPanelOrForm(ctx *context.ButtonContext, panel database.Panel, outOfHoursTitle, outOfHoursWarning *string, outOfHoursColour *int) { + if panel.FormId == nil { + _, _ = logic.OpenTicket(ctx.Context, ctx, &panel, panel.Title, nil, outOfHoursTitle, outOfHoursWarning, outOfHoursColour, database.TicketSourcePanel) + return + } - if len(inputs) == 0 { // Don't open a blank form - _, _ = logic.OpenTicket(ctx.Context, ctx, &panel, panel.Title, nil, outOfHoursTitle, outOfHoursWarning, outOfHoursColour) - } else { - modal := buildForm(panel, form, inputs, inputOptions) - ctx.Modal(modal) - } - } + form, ok, err := dbclient.Client.Forms.Get(ctx, *panel.FormId) + if err != nil { + ctx.HandleError(err) + return + } + + if !ok { + ctx.HandleError(errors.New("Form not found")) + return + } + inputs, err := dbclient.Client.FormInput.GetInputs(ctx, form.Id) + if err != nil { + ctx.HandleError(err) + return + } + + inputOptions, err := dbclient.Client.FormInputOption.GetOptionsByForm(ctx, form.Id) + if err != nil { + ctx.HandleError(err) return } + + FetchApiOptions(ctx, form, panel, inputs, inputOptions) + + if len(inputs) == 0 { // Don't open a blank form + _, _ = logic.OpenTicket(ctx.Context, ctx, &panel, panel.Title, nil, outOfHoursTitle, outOfHoursWarning, outOfHoursColour, database.TicketSourcePanel) + } else { + modal := buildForm(panel, form, inputs, inputOptions) + ctx.Modal(modal) + } } func buildFormComponents(inputs []database.FormInput, inputOptions map[int][]database.FormInputOption) []component.Component { @@ -126,11 +146,22 @@ func buildFormComponents(inputs []database.FormInput, inputOptions map[int][]dat Description: option.Description, } } + + selectMin := minLength + selectMax := maxLength + if selectMax != nil && *selectMax > len(opts) { + clamped := len(opts) + selectMax = &clamped + } + if selectMin != nil && selectMax != nil && *selectMin > *selectMax { + selectMin = selectMax + } + innerComponent = component.BuildSelectMenu(component.SelectMenu{ CustomId: input.CustomId, Options: opts, - MinValues: minLength, - MaxValues: maxLength, + MinValues: selectMin, + MaxValues: selectMax, Required: utils.Ptr(input.Required), }) // Input Text @@ -227,10 +258,15 @@ func buildFormComponents(inputs []database.FormInput, inputOptions map[int][]dat func buildForm(panel database.Panel, form database.Form, inputs []database.FormInput, inputOptions map[int][]database.FormInputOption) button.ResponseModal { return button.ResponseModal{ - Data: interaction.ModalResponseData{ - CustomId: fmt.Sprintf("form_%s", panel.CustomId), - Title: form.Title, - Components: buildFormComponents(inputs, inputOptions), - }, + Data: BuildFormModal(panel, form, inputs, inputOptions), + } +} + +// BuildFormModal returns the modal response data for a panel form, exported for use by slash command contexts. +func BuildFormModal(panel database.Panel, form database.Form, inputs []database.FormInput, inputOptions map[int][]database.FormInputOption) interaction.ModalResponseData { + return interaction.ModalResponseData{ + CustomId: fmt.Sprintf("form_%s", panel.CustomId), + Title: form.Title, + Components: buildFormComponents(inputs, inputOptions), } } diff --git a/bot/button/handlers/rate.go b/bot/button/handlers/rate.go index bf7bfc9a..ed9f6ada 100644 --- a/bot/button/handlers/rate.go +++ b/bot/button/handlers/rate.go @@ -8,6 +8,7 @@ import ( "time" "github.com/TicketsBot-cloud/common/premium" + "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/worker/bot/button/registry" "github.com/TicketsBot-cloud/worker/bot/button/registry/matcher" @@ -77,10 +78,20 @@ func (h *RateHandler) Execute(ctx *cmdcontext.ButtonContext) { return } - feedbackEnabled, err := dbclient.Client.FeedbackEnabled.Get(ctx, guildId) - if err != nil { - ctx.HandleError(err) - return + // Load panel for per-panel settings + var storeTranscripts, feedbackEnabled bool + var panel *database.Panel + if ticket.PanelId != nil { + p, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) + if err != nil { + ctx.HandleError(err) + return + } + if p.PanelId != 0 { + panel = &p + storeTranscripts = p.StoreTranscripts + feedbackEnabled = p.FeedbackEnabled + } } if !feedbackEnabled { @@ -100,13 +111,7 @@ func (h *RateHandler) Execute(ctx *cmdcontext.ButtonContext) { return } - if premiumTier > premium.None && ticket.PanelId != nil { - panel, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) - if err != nil { - ctx.HandleError(err) - return - } - + if premiumTier > premium.None && panel != nil { if panel.ExitSurveyFormId != nil { row := component.BuildActionRow(component.BuildButton(component.Button{ Label: "Complete survey", @@ -146,19 +151,13 @@ func (h *RateHandler) Execute(ctx *cmdcontext.ButtonContext) { } } - settings, err := dbclient.Client.Settings.Get(ctx, guildId) - if err != nil { - ctx.HandleError(err) - return - } - hasFeedback, err := dbclient.Client.ExitSurveyResponses.HasResponse(ctx, guildId, ticketId) if err != nil { ctx.HandleError(err) return } - if err := logic.EditGuildArchiveMessageIfExists(ctx, ctx.Worker(), ticket, settings, hasFeedback, closedBy, reason, &rating); err != nil { + if err := logic.EditGuildArchiveMessageIfExists(ctx, ctx.Worker(), ticket, storeTranscripts, hasFeedback, closedBy, reason, &rating); err != nil { ctx.HandleError(err) } } diff --git a/bot/button/handlers/unclaim.go b/bot/button/handlers/unclaim.go index 33a1ebed..f58e3cf5 100644 --- a/bot/button/handlers/unclaim.go +++ b/bot/button/handlers/unclaim.go @@ -1,14 +1,7 @@ package handlers import ( - "fmt" - "github.com/TicketsBot-cloud/common/permission" - "github.com/TicketsBot-cloud/database" - "github.com/TicketsBot-cloud/gdl/objects/channel" - discordpermission "github.com/TicketsBot-cloud/gdl/permission" - "github.com/TicketsBot-cloud/gdl/rest" - "github.com/TicketsBot-cloud/gdl/rest/request" "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" @@ -84,119 +77,8 @@ func (h *UnclaimHandler) Execute(ctx *context.ButtonContext) { return } - // Set to unclaimed in DB - if err := dbclient.Client.TicketClaims.Delete(ctx, ctx.GuildId(), ticket.Id); err != nil { - ctx.HandleError(err) - return - } - - // Get panel - var panel *database.Panel - if ticket.PanelId != nil { - tmp, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) - if err != nil { - ctx.HandleError(err) - return - } - - if tmp.GuildId != 0 { - panel = &tmp - } - } - - // Get the channel to determine its parent category - ch, err := ctx.Worker().GetChannel(ctx.ChannelId()) - if err != nil { - ctx.HandleError(err) - return - } - - // Restore original permissions - overwrites, err := logic.CreateOverwrites(ctx.Context, ctx, ticket.UserId, panel, ch.ParentId.Value) - if err != nil { - ctx.HandleError(err) - return - } - - // Handle claimer access based on SwitchPanelClaimBehavior setting - claimSettings, err := dbclient.Client.ClaimSettings.Get(ctx, ctx.GuildId()) - if err != nil { - ctx.HandleError(err) - return - } - - if claimSettings.SwitchPanelClaimBehavior == database.SwitchPanelKeepAccess || - claimSettings.SwitchPanelClaimBehavior == database.SwitchPanelRemoveOnUnclaim { - - claimerHasAccess, err := logic.HasPermissionForPanel(ctx.Context, ctx.Worker(), ctx.GuildId(), panel, whoClaimed) - if err != nil { - ctx.HandleError(err) - return - } - - if !claimerHasAccess { - filteredOverwrites := make([]channel.PermissionOverwrite, 0, len(overwrites)) - for _, ow := range overwrites { - if ow.Id != whoClaimed || ow.Type != channel.PermissionTypeMember { - filteredOverwrites = append(filteredOverwrites, ow) - } - } - overwrites = filteredOverwrites - - switch claimSettings.SwitchPanelClaimBehavior { - case database.SwitchPanelKeepAccess: - // Preserve the claimer's existing overwrite, falling back to the full set - if existing, ok := logic.FindMemberOverwrite(ch.PermissionOverwrites, whoClaimed); ok { - overwrites = append(overwrites, existing) - } else { - overwrites = append(overwrites, channel.PermissionOverwrite{ - Id: whoClaimed, - Type: channel.PermissionTypeMember, - Allow: discordpermission.BuildPermissions(logic.StandardPermissions[:]...), - Deny: 0, - }) - } - case database.SwitchPanelRemoveOnUnclaim: - overwrites = append(overwrites, channel.PermissionOverwrite{ - Id: whoClaimed, - Type: channel.PermissionTypeMember, - Allow: 0, - Deny: discordpermission.BuildPermissions(discordpermission.ViewChannel), - }) - } - } - } - - // Generate new channel name - newChannelName, err := logic.GenerateChannelName(ctx.Context, ctx.Worker(), panel, ticket.GuildId, ticket.Id, ticket.UserId, nil) - if err != nil { - ctx.HandleError(err) - return - } - - // Always update the name to match the new panel's naming scheme - shouldUpdateName := true - claimedChannelName, _ := logic.GenerateChannelName(ctx.Context, ctx.Worker(), panel, ticket.GuildId, ticket.Id, ticket.UserId, &whoClaimed) - if ch.Name != claimedChannelName { - shouldUpdateName = false - } - - // Update channel permissions - data := rest.ModifyChannelData{ - PermissionOverwrites: overwrites, - } - if shouldUpdateName { - data.Name = newChannelName - } - - member, err := ctx.Member() - auditReason := fmt.Sprintf("Unclaimed ticket %d", ticket.Id) - if err == nil { - auditReason = fmt.Sprintf("Unclaimed ticket %d by %s", ticket.Id, member.User.Username) - } - - reasonCtx := request.WithAuditReason(ctx, auditReason) - if _, err := ctx.Worker().ModifyChannel(reasonCtx, ctx.ChannelId(), data); err != nil { + // Unclaim the ticket + if err := logic.UnclaimTicket(ctx.Context, ctx, ticket, whoClaimed); err != nil { ctx.HandleError(err) return } diff --git a/bot/button/handlers/whitelabel/recreatecommands.go b/bot/button/handlers/whitelabel/recreatecommands.go new file mode 100644 index 00000000..6360c1f2 --- /dev/null +++ b/bot/button/handlers/whitelabel/recreatecommands.go @@ -0,0 +1,110 @@ +package whitelabel + +import ( + "fmt" + "strconv" + "strings" + "sync" + "time" + + permcache "github.com/TicketsBot-cloud/common/permission" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/TicketsBot-cloud/gdl/rest" + "github.com/TicketsBot-cloud/worker/bot/button/registry" + "github.com/TicketsBot-cloud/worker/bot/button/registry/matcher" + "github.com/TicketsBot-cloud/worker/bot/command" + "github.com/TicketsBot-cloud/worker/bot/command/context" + "github.com/TicketsBot-cloud/worker/bot/command/manager" + "github.com/TicketsBot-cloud/worker/bot/customisation" + "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/bot/redis" + "github.com/TicketsBot-cloud/worker/bot/utils" +) + +// The command payload is identical across bots, so build the manager once and reuse it. +var ( + commandManager *manager.CommandManager + commandManagerOnce sync.Once +) + +func getCommandManager() *manager.CommandManager { + commandManagerOnce.Do(func() { + commandManager = new(manager.CommandManager) + commandManager.RegisterCommands() + }) + return commandManager +} + +type WhitelabelRecreateCommandsHandler struct{} + +func (h *WhitelabelRecreateCommandsHandler) Matcher() matcher.Matcher { + return matcher.NewFuncMatcher(func(customId string) bool { + return strings.HasPrefix(customId, "whitelabel_recreate_commands") + }) +} + +func (h *WhitelabelRecreateCommandsHandler) Properties() registry.Properties { + return registry.Properties{ + Flags: registry.SumFlags(registry.GuildAllowed, registry.CanEdit), + Timeout: time.Second * 30, + PermissionLevel: permcache.Everyone, + } +} + +func (h *WhitelabelRecreateCommandsHandler) Execute(ctx *context.ButtonContext) { + userId, err := strconv.ParseUint(strings.TrimPrefix(ctx.InteractionData.CustomId, "whitelabel_recreate_commands_"), 10, 64) + if err != nil { + ctx.HandleError(err) + return + } + + // Bot staff or the subscription owner themselves + if !utils.IsBotHelper(ctx, ctx.UserId()) && ctx.UserId() != userId { + ctx.ReplyRaw(customisation.Red, "Error", "You do not have permission to use this button.") + return + } + + bot, err := dbclient.Client.Whitelabel.GetByUserId(ctx, userId) + if err != nil { + ctx.HandleError(err) + return + } + + if bot.BotId == 0 { + ctx.ReplyRaw(customisation.Red, "Error", "This user does not have a whitelabel bot.") + return + } + + // Cooldown to avoid Discord global-command rate limits (shared with the dashboard). + key := fmt.Sprintf("tickets:interaction-create-cooldown:%d", bot.BotId) + wasSet, err := redis.Client.SetNX(ctx, key, 1, time.Minute).Result() + if err != nil { + ctx.HandleError(err) + return + } + + if !wasSet { + ctx.ReplyRaw(customisation.Red, "Slow down", "Slash commands were re-created recently. Please wait a minute and try again.") + return + } + + commands, _ := getCommandManager().BuildCreatePayload(true, nil) + + if _, err := rest.ModifyGlobalCommands(ctx, bot.Token, nil, bot.BotId, commands); err != nil { + ctx.HandleError(err) + return + } + + ctx.ReplyWith(command.NewMessageResponseWithComponents([]component.Component{ + utils.BuildContainerWithComponents( + ctx, + customisation.Green, + "Whitelabel - Re-create Slash Commands", + []component.Component{ + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("Slash commands for <@%d> have been re-created. They may take a few minutes to appear.", bot.BotId), + }), + }, + ), + })) +} diff --git a/bot/button/handlers/whitelabel/resync.go b/bot/button/handlers/whitelabel/resync.go new file mode 100644 index 00000000..17781372 --- /dev/null +++ b/bot/button/handlers/whitelabel/resync.go @@ -0,0 +1,99 @@ +package whitelabel + +import ( + "fmt" + "strconv" + "strings" + "time" + + permcache "github.com/TicketsBot-cloud/common/permission" + "github.com/TicketsBot-cloud/common/tokenchange" + commonwl "github.com/TicketsBot-cloud/common/whitelabel" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/TicketsBot-cloud/worker/bot/button/registry" + "github.com/TicketsBot-cloud/worker/bot/button/registry/matcher" + "github.com/TicketsBot-cloud/worker/bot/command" + "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/redis" + "github.com/TicketsBot-cloud/worker/bot/utils" +) + +type WhitelabelResyncHandler struct{} + +func (h *WhitelabelResyncHandler) Matcher() matcher.Matcher { + return matcher.NewFuncMatcher(func(customId string) bool { + return strings.HasPrefix(customId, "whitelabel_resync") + }) +} + +func (h *WhitelabelResyncHandler) Properties() registry.Properties { + return registry.Properties{ + Flags: registry.SumFlags(registry.GuildAllowed, registry.CanEdit), + Timeout: time.Second * 30, + PermissionLevel: permcache.Everyone, + } +} + +func (h *WhitelabelResyncHandler) Execute(ctx *context.ButtonContext) { + userId, err := strconv.ParseUint(strings.TrimPrefix(ctx.InteractionData.CustomId, "whitelabel_resync_"), 10, 64) + if err != nil { + ctx.HandleError(err) + return + } + + // Bot staff or the subscription owner themselves + if !utils.IsBotHelper(ctx, ctx.UserId()) && ctx.UserId() != userId { + ctx.ReplyRaw(customisation.Red, "Error", "You do not have permission to use this button.") + return + } + + bot, err := dbclient.Client.Whitelabel.GetByUserId(ctx, userId) + if err != nil { + ctx.HandleError(err) + return + } + + if bot.BotId == 0 { + ctx.ReplyRaw(customisation.Red, "Error", "This user does not have a whitelabel bot.") + return + } + + if err := commonwl.ReapplyIntents(ctx, bot.Token); err != nil { + if commonwl.IsIntentsRejection(err) { + ctx.ReplyRaw(customisation.Red, "Error", commonwl.IntentsRejectedMessage) + } else { + ctx.HandleError(err) + } + + return + } + + if err := tokenchange.PublishTokenChange(redis.Client, tokenchange.TokenChangeData{ + Token: bot.Token, + NewId: bot.BotId, + OldId: 0, + }); err != nil { + ctx.HandleError(err) + return + } + + if err := commonwl.SyncGuilds(ctx, dbclient.Client, bot.Token, bot.BotId); err != nil { + ctx.HandleError(err) + return + } + + ctx.ReplyWith(command.NewMessageResponseWithComponents([]component.Component{ + utils.BuildContainerWithComponents( + ctx, + customisation.Green, + "Whitelabel - Resync", + []component.Component{ + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("Bot <@%d> has been resynced.", bot.BotId), + }), + }, + ), + })) +} diff --git a/bot/button/manager/handler.go b/bot/button/manager/handler.go index 010d51ba..fd96035b 100644 --- a/bot/button/manager/handler.go +++ b/bot/button/manager/handler.go @@ -195,7 +195,7 @@ func doPropertiesChecks(ctx context.Context, guildId uint64, cmd cmdregistry.Com } } - if properties.HelperOnly && !utils.IsBotHelper(cmd.UserId()) { + if properties.HelperOnly && !utils.IsBotHelper(ctx, cmd.UserId()) { cmd.Reply(customisation.Red, i18n.Error, i18n.MessageNoPermission) return false, false } diff --git a/bot/button/manager/manager.go b/bot/button/manager/manager.go index 498c8f54..9b0edbdd 100644 --- a/bot/button/manager/manager.go +++ b/bot/button/manager/manager.go @@ -5,6 +5,7 @@ import ( "github.com/TicketsBot-cloud/worker/bot/button/handlers/admindebug/server" "github.com/TicketsBot-cloud/worker/bot/button/handlers/admindebug/server/modals" "github.com/TicketsBot-cloud/worker/bot/button/handlers/tickets/edit" + whitelabelbtn "github.com/TicketsBot-cloud/worker/bot/button/handlers/whitelabel" "github.com/TicketsBot-cloud/worker/bot/button/registry" "github.com/TicketsBot-cloud/worker/bot/button/registry/matcher" ) @@ -71,6 +72,8 @@ func (m *ComponentInteractionManager) RegisterCommands() { new(handlers.GDPRConfirmAllMessagesHandler), new(handlers.GDPRConfirmMessagesHandler), new(handlers.JoinThreadHandler), + new(handlers.KBNavigationHandler), + new(handlers.KBCreateTicketHandler), new(handlers.OpenSurveyHandler), new(handlers.PanelHandler), new(handlers.PremiumCheckAgain), @@ -88,9 +91,12 @@ func (m *ComponentInteractionManager) RegisterCommands() { new(server.AdminDebugServerTicketPermissionsHandler), new(server.AdminDebugServerUserTicketsHandler), new(edit.EditLabelsButtonHandler), + new(whitelabelbtn.WhitelabelResyncHandler), + new(whitelabelbtn.WhitelabelRecreateCommandsHandler), ) m.selectRegistry = append(m.selectRegistry, + new(handlers.KBCategorySelectHandler), new(handlers.LanguageSelectorHandler), new(handlers.MultiPanelHandler), new(handlers.PremiumKeyOpenHandler), diff --git a/bot/button/manager/modal.go b/bot/button/manager/modal.go index af921bfb..da2d4af2 100644 --- a/bot/button/manager/modal.go +++ b/bot/button/manager/modal.go @@ -14,7 +14,7 @@ import ( "github.com/TicketsBot-cloud/worker/config" ) -func HandleModalInteraction(ctx context.Context, manager *ComponentInteractionManager, worker *worker.Context, data interaction.ModalSubmitInteraction, responseCh chan button.Response) bool { +func HandleModalInteraction(ctx context.Context, manager *ComponentInteractionManager, worker *worker.Context, data interaction.ModalSubmitInteraction, responseCh chan button.Response, hasSourceMessage bool) bool { // Safety checks if data.GuildId.Value != 0 && data.Member == nil { return false @@ -48,7 +48,7 @@ func HandleModalInteraction(ctx context.Context, manager *ComponentInteractionMa ctx, cancel := context.WithTimeout(ctx, handler.Properties().Timeout) - cc := cmdcontext.NewModalContext(ctx, worker, data, premiumTier, responseCh) + cc := cmdcontext.NewModalContext(ctx, worker, data, premiumTier, responseCh, hasSourceMessage) shouldExecute, canEdit := doPropertiesChecks(lookupCtx, data.GuildId.Value, cc, handler.Properties()) if shouldExecute { go func() { diff --git a/bot/command/context/modalcontext.go b/bot/command/context/modalcontext.go index 2c8de6ab..745d279f 100644 --- a/bot/command/context/modalcontext.go +++ b/bot/command/context/modalcontext.go @@ -32,11 +32,12 @@ type ModalContext struct { *ReplyCounter *MessageComponentExtensions *StateCache - worker *worker.Context - Interaction interaction.ModalSubmitInteraction - premium premium.PremiumTier - hasReplied *atomic.Bool - responseChannel chan button.Response + worker *worker.Context + Interaction interaction.ModalSubmitInteraction + premium premium.PremiumTier + hasReplied *atomic.Bool + responseChannel chan button.Response + hasSourceMessage bool } var _ registry.CommandContext = (*ModalContext)(nil) @@ -47,15 +48,17 @@ func NewModalContext( interaction interaction.ModalSubmitInteraction, premium premium.PremiumTier, responseChannel chan button.Response, + hasSourceMessage bool, ) *ModalContext { c := ModalContext{ - Context: ctx, - ReplyCounter: NewReplyCounter(), - worker: worker, - Interaction: interaction, - premium: premium, - hasReplied: atomic.NewBool(false), - responseChannel: responseChannel, + Context: ctx, + ReplyCounter: NewReplyCounter(), + worker: worker, + Interaction: interaction, + premium: premium, + hasReplied: atomic.NewBool(false), + responseChannel: responseChannel, + hasSourceMessage: hasSourceMessage, } c.Replyable = NewReplyable(&c) @@ -65,8 +68,11 @@ func NewModalContext( } func (c *ModalContext) Defer() { - c.hasReplied.Store(true) - c.Ack() + if c.hasSourceMessage { + c.hasReplied.Store(true) + c.Ack() + return + } } func (c *ModalContext) GetInput(customId string) (string, bool) { @@ -145,8 +151,14 @@ func (c *ModalContext) ReplyWith(response command.MessageResponse) (msg message. } if !hasReplied { - c.responseChannel <- button.ResponseMessage{ - Data: response, + if c.hasSourceMessage { + c.responseChannel <- button.ResponseMessage{ + Data: response, + } + } else { + c.responseChannel <- button.ResponseEdit{ + Data: response, + } } } else { if time.Now().Sub(utils.SnowflakeToTime(c.interaction.Id)) > time.Minute*14 { diff --git a/bot/command/context/replyable.go b/bot/command/context/replyable.go index 0e14f07f..ee991d82 100644 --- a/bot/command/context/replyable.go +++ b/bot/command/context/replyable.go @@ -316,20 +316,12 @@ func findMissingPermissions(ctx registry.InteractionContext) ([]permission.Permi var useThreads bool var targetChannelId uint64 - settings, err := ctx.Settings() - if err == nil { - useThreads = settings.UseThreads - } - var panel *database.Panel if btnCtx, ok := ctx.(*ButtonContext); ok { p, panelExists, err := dbclient.Client.Panel.GetByCustomId(context.Background(), ctx.GuildId(), btnCtx.InteractionData.CustomId) if err == nil && panelExists { panel = &p - // Panel can enable threads if global setting is disabled - if !useThreads { - useThreads = panel.UseThreads - } + useThreads = panel.UseThreads } } @@ -337,13 +329,9 @@ func findMissingPermissions(ctx registry.InteractionContext) ([]permission.Permi // Thread mode - check permissions in the current channel targetChannelId = ctx.ChannelId() } else { - // Channel mode - check permissions in the ticket category - if panel != nil && panel.TargetCategory != 0 { - // Use panel's target category + // Channel mode - check permissions in the panel's category (0 = no category, skip check) + if panel != nil { targetChannelId = panel.TargetCategory - } else { - // Fall back to guild default category - targetChannelId, _ = dbclient.Client.ChannelCategory.Get(context.Background(), ctx.GuildId()) } } diff --git a/bot/command/impl/admin/adminrecache.go b/bot/command/impl/admin/adminrecache.go index fd0a1244..267752dd 100644 --- a/bot/command/impl/admin/adminrecache.go +++ b/bot/command/impl/admin/adminrecache.go @@ -28,7 +28,7 @@ func (AdminRecacheCommand) Properties() registry.Properties { Category: command.Settings, HelperOnly: true, Arguments: command.Arguments( - command.NewOptionalArgument("guildid", "ID of the guild to recache", interaction.OptionTypeString, i18n.MessageInvalidArgument), + command.NewRequiredArgument("guild_id", "ID of the guild to recache", interaction.OptionTypeString, i18n.MessageInvalidArgument), ), Timeout: time.Second * 10, } @@ -38,17 +38,11 @@ func (c AdminRecacheCommand) GetExecutor() interface{} { return c.Execute } -func (AdminRecacheCommand) Execute(ctx registry.CommandContext, providedGuildId *string) { - var guildId uint64 - if providedGuildId != nil { - var err error - guildId, err = strconv.ParseUint(*providedGuildId, 10, 64) - if err != nil { - ctx.HandleError(err) - return - } - } else { - guildId = ctx.GuildId() +func (AdminRecacheCommand) Execute(ctx registry.CommandContext, guildIdRaw string) { + guildId, err := strconv.ParseUint(guildIdRaw, 10, 64) + if err != nil { + ctx.ReplyRaw(customisation.Red, ctx.GetMessage(i18n.Error), "Invalid guild ID provided") + return } if onCooldown, cooldownTime := redis.GetRecacheCooldown(guildId); onCooldown { diff --git a/bot/command/impl/admin/adminwhitelabeldata.go b/bot/command/impl/admin/adminwhitelabeldata.go index ea1f3803..bfeef989 100644 --- a/bot/command/impl/admin/adminwhitelabeldata.go +++ b/bot/command/impl/admin/adminwhitelabeldata.go @@ -134,17 +134,29 @@ func (AdminWhitelabelDataCommand) Execute(ctx registry.CommandContext, userId ui } innerComponents := []component.Component{ - component.BuildTextDisplay(component.TextDisplay{Content: "## Whitelabel"}), - component.BuildSeparator(component.Separator{}), component.BuildTextDisplay(component.TextDisplay{ Content: tds, }), } - ctx.ReplyWith(command.NewMessageResponseWithComponents(utils.Slice(utils.BuildContainerWithComponents( - ctx, - customisation.Green, - "Admin - Whitelabel Data", - innerComponents, - )))) + components := []component.Component{ + utils.BuildContainerWithComponents(ctx, customisation.Green, "Admin - Whitelabel Data", innerComponents), + } + + if data.BotId != 0 { + components = append(components, component.BuildActionRow( + component.BuildButton(component.Button{ + Label: "Resync Bot", + Style: component.ButtonStylePrimary, + CustomId: fmt.Sprintf("whitelabel_resync_%d", userId), + }), + component.BuildButton(component.Button{ + Label: "Re-create Slash Commands", + Style: component.ButtonStyleSecondary, + CustomId: fmt.Sprintf("whitelabel_recreate_commands_%d", userId), + }), + )) + } + + ctx.ReplyWith(command.NewMessageResponseWithComponents(components)) } diff --git a/bot/command/impl/admin/debug/debugserver.go b/bot/command/impl/admin/debug/debugserver.go index 4619f915..34a860fd 100644 --- a/bot/command/impl/admin/debug/debugserver.go +++ b/bot/command/impl/admin/debug/debugserver.go @@ -146,12 +146,6 @@ func (AdminDebugServerCommand) Execute(ctx registry.CommandContext, raw string) return } - settings, err := dbclient.Client.Settings.Get(ctx, guild.Id) - if err != nil { - ctx.HandleError(err) - return - } - owner, err := worker.GetUser(guild.OwnerId) if err != nil { ctx.HandleError(err) @@ -192,19 +186,6 @@ func (AdminDebugServerCommand) Execute(ctx registry.CommandContext, raw string) } } - // Helper to get ticket notification channel info - getTicketNotifChannel := func() (string, string) { - if settings.UseThreads && settings.TicketNotificationChannel != nil { - ch, err := worker.GetChannel(*settings.TicketNotificationChannel) - if err == nil { - return ch.Name, strconv.FormatUint(ch.Id, 10) - } - } - return "Disabled", "Disabled" - } - - ticketNotifChannelName, ticketNotifChannelId := getTicketNotifChannel() - panelLimit := "3" premiumTier := "None" premiumSource := "None" @@ -283,37 +264,23 @@ func (AdminDebugServerCommand) Execute(ctx registry.CommandContext, raw string) } guildInfo = append(guildInfo, fmt.Sprintf("Server Blacklisted: `%t`", IsGuildBlacklisted)) - // Count panels with per-panel thread mode override - perPanelThreadModeCount := 0 + // Count panels using thread mode + threadPanelCount := 0 for _, panel := range panels { - if panel.UseThreads != settings.UseThreads { - perPanelThreadModeCount++ + if panel.UseThreads { + threadPanelCount++ } } - ticketMode := "Channel Mode" - if settings.UseThreads { - ticketMode = "Thread Mode" - } - // Check if bot has administrator permission hasAdministrator := permissionwrapper.HasPermissions(worker, guild.Id, worker.BotId, permission.Administrator) settingsInfo := []string{ - fmt.Sprintf("Transcripts Enabled: `%t`", settings.StoreTranscripts), fmt.Sprintf("Panel Count: `%d/%s`", panelCount, panelLimit), - fmt.Sprintf("Ticket Mode: `%s`", ticketMode), + fmt.Sprintf("Thread Mode Panels: `%d/%d`", threadPanelCount, panelCount), fmt.Sprintf("Bot Has Administrator: `%t`", hasAdministrator), } - if perPanelThreadModeCount > 0 && !settings.UseThreads { - settingsInfo = append(settingsInfo, fmt.Sprintf("Per-Panel Thread Mode: `%d/%d panels`", perPanelThreadModeCount, panelCount)) - } - - if settings.UseThreads { - settingsInfo = append(settingsInfo, fmt.Sprintf("Notification Channel: `#%s` (%s)", ticketNotifChannelName, ticketNotifChannelId)) - } - if len(integrations) > 0 { enabledIntegrations := make([]string, len(integrations)) for i, integ := range integrations { diff --git a/bot/command/impl/general/help.go b/bot/command/impl/general/help.go index 7e7ccc09..60b83e1e 100644 --- a/bot/command/impl/general/help.go +++ b/bot/command/impl/general/help.go @@ -65,7 +65,7 @@ func (c HelpCommand) Execute(ctx registry.CommandContext) { properties := cmd.Properties() // check bot admin / helper only commands - if (properties.AdminOnly && !utils.IsBotAdmin(ctx.UserId())) || (properties.HelperOnly && !utils.IsBotHelper(ctx.UserId())) { + if (properties.AdminOnly && !utils.IsBotAdmin(ctx, ctx.UserId())) || (properties.HelperOnly && !utils.IsBotHelper(ctx, ctx.UserId())) { continue } diff --git a/bot/command/impl/kb/browse.go b/bot/command/impl/kb/browse.go new file mode 100644 index 00000000..bcb316e7 --- /dev/null +++ b/bot/command/impl/kb/browse.go @@ -0,0 +1,120 @@ +package kb + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/TicketsBot-cloud/common/permission" + "github.com/TicketsBot-cloud/common/sentry" + "github.com/TicketsBot-cloud/gdl/objects/interaction" + "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/i18n" +) + +type KBBrowseCommand struct { +} + +func (c KBBrowseCommand) Properties() registry.Properties { + return registry.Properties{ + Name: "browse", + Description: i18n.HelpKbBrowse, + Type: interaction.ApplicationCommandTypeChatInput, + PermissionLevel: permission.Everyone, + Category: command.General, + Arguments: command.Arguments( + command.NewOptionalAutocompleteableArgument("category", "The category to browse", interaction.OptionTypeString, i18n.MessageInvalidArgument, c.AutoCompleteHandler), + ), + DefaultEphemeral: true, + Timeout: time.Second * 7, + } +} + +func (c KBBrowseCommand) GetExecutor() interface{} { + return c.Execute +} + +func (KBBrowseCommand) Execute(ctx registry.CommandContext, categoryIdStr *string) { + categories, err := dbclient.Client.KBCategories.GetByGuild(ctx, ctx.GuildId()) + if err != nil { + ctx.HandleError(err) + return + } + + // If a category was provided, show its articles directly. + if categoryIdStr != nil && *categoryIdStr != "" { + var categoryId int + if _, scanErr := fmt.Sscanf(*categoryIdStr, "%d", &categoryId); scanErr != nil { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageInvalidArgument) + return + } + + var categoryName string + for _, cat := range categories { + if cat.Id == categoryId { + categoryName = cat.Name + break + } + } + + if categoryName == "" { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageInvalidArgument) + return + } + + articles, err := dbclient.Client.KBArticles.GetByCategory(ctx, ctx.GuildId(), categoryId) + if err != nil { + ctx.HandleError(err) + return + } + + if _, err := ctx.ReplyWith(command.NewEphemeralMessageResponseWithComponents( + BuildArticleList(ctx, categoryName, articles, SourceBrowse), + )); err != nil { + ctx.HandleError(err) + } + return + } + + // No category specified: show the interactive category picker. + if _, err := ctx.ReplyWith(command.NewEphemeralMessageResponseWithComponents( + BuildCategoryPicker(ctx, categories), + )); err != nil { + ctx.HandleError(err) + } +} + +func (KBBrowseCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) + defer cancel() + + categories, err := dbclient.Client.KBCategories.GetByGuild(ctx, data.GuildId.Value) + if err != nil { + sentry.Error(err) + return nil + } + + loweredValue := strings.ToLower(value) + + var choices []interaction.ApplicationCommandOptionChoice + for _, cat := range categories { + if value != "" && !strings.Contains(strings.ToLower(cat.Name), loweredValue) { + continue + } + + choices = append(choices, interaction.ApplicationCommandOptionChoice{ + Name: cat.Name, + Value: fmt.Sprintf("%d", cat.Id), + }) + + if len(choices) >= 25 { + break + } + } + + return choices +} diff --git a/bot/command/impl/kb/kb.go b/bot/command/impl/kb/kb.go new file mode 100644 index 00000000..9021e452 --- /dev/null +++ b/bot/command/impl/kb/kb.go @@ -0,0 +1,39 @@ +package kb + +import ( + "time" + + "github.com/TicketsBot-cloud/common/permission" + "github.com/TicketsBot-cloud/gdl/objects/interaction" + "github.com/TicketsBot-cloud/worker/bot/command" + "github.com/TicketsBot-cloud/worker/bot/command/registry" + "github.com/TicketsBot-cloud/worker/i18n" +) + +type KBCommand struct { +} + +func (KBCommand) Properties() registry.Properties { + return registry.Properties{ + Name: "kb", + Description: i18n.HelpKb, + Type: interaction.ApplicationCommandTypeChatInput, + PermissionLevel: permission.Everyone, + Category: command.General, + Children: []registry.Command{ + KBSearchCommand{}, + KBBrowseCommand{}, + KBSendCommand{}, + }, + DefaultEphemeral: true, + Timeout: time.Second * 7, + } +} + +func (c KBCommand) GetExecutor() interface{} { + return c.Execute +} + +func (KBCommand) Execute(ctx registry.CommandContext) { + // Parent commands cannot be called directly +} diff --git a/bot/command/impl/kb/render.go b/bot/command/impl/kb/render.go new file mode 100644 index 00000000..a8e4c252 --- /dev/null +++ b/bot/command/impl/kb/render.go @@ -0,0 +1,384 @@ +package kb + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/objects" + "github.com/TicketsBot-cloud/gdl/objects/guild/emoji" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/TicketsBot-cloud/worker/bot/command/registry" + "github.com/TicketsBot-cloud/worker/bot/customisation" + "github.com/TicketsBot-cloud/worker/bot/utils" + "github.com/TicketsBot-cloud/worker/i18n" +) + +// Source identifies which command a knowledge base card was rendered from, so that +// navigation buttons can send the user back to a sensible place. +const ( + SourceBrowse = "browse" + SourceSearch = "search" + // SourcePublic marks a card sent publicly by /kb send. Public cards carry no + // navigation buttons because any member could click them. + SourcePublic = "" + + // sourceDeflectPrefix marks a navigation source as belonging to a panel deflection + // card, encoding the panel custom id so Back returns to the suggestions rather than + // the main category picker. Panel custom ids are alphanumeric, so they never contain + // a colon and survive customId parsing intact. + sourceDeflectPrefix = "deflect-" +) + +// DeflectionSource encodes the panel a deflection card belongs to into a navigation +// source token, so Back from a suggested article returns to that panel's suggestions. +func DeflectionSource(panelCustomId string) string { + return sourceDeflectPrefix + panelCustomId +} + +// PanelCustomIdFromSource returns the panel custom id carried by a deflection source +// token, and whether the token was a deflection source at all. +func PanelCustomIdFromSource(src string) (string, bool) { + return strings.CutPrefix(src, sourceDeflectPrefix) +} + +const ( + // maxCategoryOptions is Discord's hard cap on options in a single string select. + maxCategoryOptions = 25 + + // maxArticlesPerList caps how many article cards a single list renders, to stay + // within Discord's 40-component-per-message budget. + maxArticlesPerList = 6 + + // textDisplayLimit is the maximum length of a single Text Display component. + textDisplayLimit = 4000 + + // snippetLength is the length of the one-line preview shown in article lists. + snippetLength = 100 +) + +// customEmojiPattern matches a Discord custom emoji mention, e.g. <:name:123> or . +var customEmojiPattern = regexp.MustCompile(`^<(a?):([a-zA-Z0-9_]+):(\d+)>$`) + +// BuildCategoryPicker renders the knowledge base landing card: a string select +// listing every category. Exported because the button and select handlers live in +// a separate package. +func BuildCategoryPicker(ctx registry.CommandContext, categories []database.KBCategory) []component.Component { + if len(categories) == 0 { + return utils.Slice(utils.BuildContainer(ctx, customisation.Red, i18n.MessageKbSelectCategory, i18n.MessageKbNoCategories)) + } + + if len(categories) > maxCategoryOptions { + categories = categories[:maxCategoryOptions] + } + + options := make([]component.SelectOption, 0, len(categories)) + for _, cat := range categories { + option := component.SelectOption{ + Label: truncate(cat.Name, 100), + Value: strconv.Itoa(cat.Id), + } + + if cat.Emoji != nil && *cat.Emoji != "" { + option.Emoji = parseEmoji(*cat.Emoji) + } + + options = append(options, option) + } + + selectRow := component.BuildActionRow(component.BuildSelectMenu(component.SelectMenu{ + CustomId: "kb:cat", + Options: options, + Placeholder: ctx.GetMessage(i18n.MessageKbSelectCategory), + })) + + return utils.Slice(utils.BuildContainerWithComponents(ctx, customisation.Green, i18n.MessageKbSelectCategory, utils.Slice(selectRow))) +} + +// publishedArticles filters an article slice down to only published articles, +// preserving order. +func publishedArticles(articles []database.KBArticle) []database.KBArticle { + published := make([]database.KBArticle, 0, len(articles)) + for _, article := range articles { + if article.Published { + published = append(published, article) + } + } + + return published +} + +// buildArticleSections renders up to maxArticlesPerList published articles as +// Read-able sections, appending a note when more articles exist than are shown. src +// drives the Read button target. +func buildArticleSections(ctx registry.CommandContext, published []database.KBArticle, src string) []component.Component { + total := len(published) + shown := published + if total > maxArticlesPerList { + shown = published[:maxArticlesPerList] + } + + inner := make([]component.Component, 0, len(shown)*2+2) + for i, article := range shown { + if i > 0 { + inner = append(inner, component.BuildSeparator(component.Separator{})) + } + + // Show the description when set, otherwise a placeholder nudging an author to add + // one. Rendered as subtext so it stays small and grey beneath the title. + preview := articleDescription(article) + if preview == "" { + preview = ctx.GetMessage(i18n.MessageKbNoDescription) + } + text := fmt.Sprintf("**%s**\n-# %s", article.Title, preview) + + inner = append(inner, component.BuildSection(component.Section{ + Components: utils.Slice(component.BuildTextDisplay(component.TextDisplay{Content: text})), + Accessory: component.BuildButton(component.Button{ + Label: ctx.GetMessage(i18n.MessageKbRead), + CustomId: fmt.Sprintf("kb:read:%d:%s", article.Id, src), + Style: component.ButtonStyleSecondary, + }), + })) + } + + // Never silently drop articles: tell the user more exist. + if total > maxArticlesPerList { + inner = append(inner, + component.BuildSeparator(component.Separator{}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("-# %s", ctx.GetMessage(i18n.MessageKbMoreArticles, total-maxArticlesPerList)), + }), + ) + } + + return inner +} + +// BuildArticleList renders a list of published articles as sections, each with a +// Read button. src drives the Read button target and whether a Back button is shown. +func BuildArticleList(ctx registry.CommandContext, title string, articles []database.KBArticle, src string) []component.Component { + published := publishedArticles(articles) + + colour := customisation.Green + var inner []component.Component + + if len(published) == 0 { + colour = customisation.Red + inner = append(inner, component.BuildTextDisplay(component.TextDisplay{ + Content: ctx.GetMessage(i18n.MessageKbNoArticlesFound), + })) + } else { + inner = buildArticleSections(ctx, published, src) + } + + // Navigation and calls to action apply whether or not the list has results, so the + // user is never stranded (browse) and always sees the ticket affordance (search). + switch src { + case SourceBrowse: + inner = append(inner, component.BuildActionRow(backButton(ctx, "kb:home"))) + case SourceSearch: + // TODO(review): decide create-ticket CTA behaviour. There is no panel-less + // ticket-open in this codebase, so this is a text-only affordance guiding the + // user to open a ticket, with no button action. + inner = append(inner, + component.BuildSeparator(component.Separator{}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("-# %s", ctx.GetMessage(i18n.MessageKbCreateTicket)), + }), + ) + } + + return utils.Slice(utils.BuildContainerWithComponents(ctx, colour, title, inner)) +} + +// BuildDeflectionCard renders the ticket-deflection card shown before opening a +// ticket from a panel that has linked knowledge base categories: suggested articles +// plus a "Create ticket anyway" button. Callers must guarantee at least one published +// article, so an empty deflection card is never shown. +// +// The Read buttons carry a deflection source encoding the panel, so opening a suggested +// article and then pressing Back returns to these suggestions rather than the category +// picker. +func BuildDeflectionCard(ctx registry.CommandContext, panel database.Panel, articles []database.KBArticle) []component.Component { + inner := buildArticleSections(ctx, publishedArticles(articles), DeflectionSource(panel.CustomId)) + + inner = append(inner, + component.BuildSeparator(component.Separator{}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("-# %s", ctx.GetMessage(i18n.MessageKbSuggestFooter)), + }), + component.BuildActionRow(component.BuildButton(component.Button{ + Label: ctx.GetMessage(i18n.MessageKbCreateTicket), + CustomId: fmt.Sprintf("kbopen:%s", panel.CustomId), + Style: component.ButtonStylePrimary, + })), + ) + + return utils.Slice(utils.BuildContainerWithComponents(ctx, customisation.Green, i18n.MessageKbSuggestTitle, inner)) +} + +// BuildArticleView renders a single article: its content, an optional image, and an +// action row. Interactive (ephemeral) sources get a Back button; public cards from +// /kb send carry none. Every view offers a "this helped" feedback button so article +// usefulness can be measured. When feedbackGiven is true the feedback button is +// replaced by a disabled acknowledgement, confirming the vote in place. +func BuildArticleView(ctx registry.CommandContext, article database.KBArticle, src string, feedbackGiven bool) []component.Component { + inner := make([]component.Component, 0, 4) + + content := utils.ValueOrZero(article.Content) + if strings.TrimSpace(content) == "" { + inner = append(inner, component.BuildTextDisplay(component.TextDisplay{ + Content: ctx.GetMessage(i18n.MessageKbNoArticlesFound), + })) + } else { + for _, chunk := range splitContent(content, textDisplayLimit) { + inner = append(inner, component.BuildTextDisplay(component.TextDisplay{Content: chunk})) + } + } + + if imageUrl := articleImageUrl(article); imageUrl != "" { + inner = append(inner, component.BuildMediaGallery(component.MediaGallery{ + Items: []component.MediaGalleryItem{ + {Media: component.UnfurledMediaItem{Url: imageUrl}}, + }, + })) + } + + buttons := make([]component.Component, 0, 2) + if src != SourcePublic { + buttons = append(buttons, backButton(ctx, fmt.Sprintf("kb:back:%s", src))) + } + buttons = append(buttons, feedbackButton(ctx, article.Id, src, feedbackGiven)) + inner = append(inner, component.BuildActionRow(buttons...)) + + return utils.Slice(utils.BuildContainerWithComponents(ctx, customisation.Green, article.Title, inner)) +} + +// feedbackButton renders the "this helped" affordance. Once a vote is recorded it +// becomes a disabled acknowledgement so the user sees their vote landed and cannot +// double-submit from the same view. +func feedbackButton(ctx registry.CommandContext, articleId int, src string, given bool) component.Component { + if given { + return component.BuildButton(component.Button{ + Label: ctx.GetMessage(i18n.MessageKbFeedbackThanks), + CustomId: "kb:feedback-done", + Style: component.ButtonStyleSuccess, + Disabled: true, + }) + } + + return component.BuildButton(component.Button{ + Label: ctx.GetMessage(i18n.MessageKbFeedbackHelpful), + CustomId: fmt.Sprintf("kb:helpful:%d:%s", articleId, src), + Style: component.ButtonStyleSuccess, + }) +} + +func backButton(ctx registry.CommandContext, customId string) component.Component { + return component.BuildButton(component.Button{ + Label: ctx.GetMessage(i18n.MessageKbBack), + CustomId: customId, + Style: component.ButtonStyleSecondary, + }) +} + +// articleDescription returns the article's cleaned, truncated description for use as a +// list preview, or "" when no description is set (the caller then shows a placeholder). +func articleDescription(article database.KBArticle) string { + if article.Description == nil { + return "" + } + + desc := strings.TrimSpace(mdWhitespace.ReplaceAllString(*article.Description, " ")) + if desc == "" { + return "" + } + + return truncateSnippet(desc, snippetLength) +} + +// truncateSnippet shortens s to at most limit runes, preferring a word boundary, and +// appends an ellipsis when it trims anything. +func truncateSnippet(s string, limit int) string { + runes := []rune(s) + if len(runes) <= limit { + return s + } + + cut := string(runes[:limit]) + if idx := strings.LastIndex(cut, " "); idx > limit/2 { + cut = cut[:idx] + } + + return strings.TrimSpace(cut) + "..." +} + +// mdWhitespace collapses runs of whitespace (including newlines) so a description +// renders as a single tidy preview line. +var mdWhitespace = regexp.MustCompile(`\s+`) + +// articleImageUrl returns the image URL from an article's stored custom embed, if any. +func articleImageUrl(article database.KBArticle) string { + if article.Embed == nil || article.Embed.CustomEmbed == nil || article.Embed.ImageUrl == nil { + return "" + } + + return *article.Embed.ImageUrl +} + +// splitContent breaks content into chunks no longer than limit runes, preferring to +// split on newlines so lines are not cut mid-way where possible. It counts runes, not +// bytes, so multibyte characters are never split. +func splitContent(content string, limit int) []string { + runes := []rune(content) + if len(runes) <= limit { + return []string{content} + } + + var chunks []string + for len(runes) > limit { + split := limit + if idx := strings.LastIndex(string(runes[:limit]), "\n"); idx > 0 { + // idx is a byte offset into the substring, so convert back to a rune count. + split = len([]rune(string(runes[:limit])[:idx])) + } + + chunks = append(chunks, string(runes[:split])) + runes = runes[split:] + } + + if len(runes) > 0 { + chunks = append(chunks, string(runes)) + } + + return chunks +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + + return s[:max] +} + +// parseEmoji converts a stored emoji string into a Discord emoji object. It handles +// both custom emoji mentions (<:name:id>) and unicode emoji. An unparsed custom +// mention would otherwise cause Discord to reject the whole message. +func parseEmoji(raw string) *emoji.Emoji { + if matches := customEmojiPattern.FindStringSubmatch(raw); matches != nil { + id, err := strconv.ParseUint(matches[3], 10, 64) + if err == nil { + return &emoji.Emoji{ + Id: objects.NewNullableSnowflake(id), + Name: matches[2], + Animated: matches[1] == "a", + } + } + } + + return utils.BuildEmoji(raw) +} diff --git a/bot/command/impl/kb/render_test.go b/bot/command/impl/kb/render_test.go new file mode 100644 index 00000000..9c72e624 --- /dev/null +++ b/bot/command/impl/kb/render_test.go @@ -0,0 +1,55 @@ +package kb + +import ( + "strings" + "testing" + + "github.com/TicketsBot-cloud/database" + "github.com/stretchr/testify/require" +) + +func ptr(s string) *string { return &s } + +func TestArticleDescription(t *testing.T) { + tests := []struct { + name string + desc *string + want string + wantLen int // 0 means compare exactly to want + }{ + { + name: "nil description yields empty", + desc: nil, + want: "", + }, + { + name: "blank description yields empty", + desc: ptr(" \n\t "), + want: "", + }, + { + name: "whitespace and newlines collapse to a single line", + desc: ptr("Manage your\nbilling and invoices."), + want: "Manage your billing and invoices.", + }, + { + name: "long description is truncated with an ellipsis", + desc: ptr(strings.Repeat("word ", 60)), + wantLen: snippetLength + 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := articleDescription(database.KBArticle{Description: tt.desc}) + + if tt.wantLen > 0 { + require.LessOrEqual(t, len([]rune(got)), tt.wantLen) + require.True(t, strings.HasSuffix(got, "..."), "expected ellipsis, got %q", got) + return + } + + require.Equal(t, tt.want, got) + }) + } +} diff --git a/bot/command/impl/kb/search.go b/bot/command/impl/kb/search.go new file mode 100644 index 00000000..21e77051 --- /dev/null +++ b/bot/command/impl/kb/search.go @@ -0,0 +1,81 @@ +package kb + +import ( + "context" + "fmt" + "time" + + "github.com/TicketsBot-cloud/common/permission" + "github.com/TicketsBot-cloud/common/sentry" + "github.com/TicketsBot-cloud/gdl/objects/interaction" + "github.com/TicketsBot-cloud/worker/bot/command" + "github.com/TicketsBot-cloud/worker/bot/command/registry" + "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/i18n" +) + +type KBSearchCommand struct { +} + +func (c KBSearchCommand) Properties() registry.Properties { + return registry.Properties{ + Name: "search", + Description: i18n.HelpKbSearch, + Type: interaction.ApplicationCommandTypeChatInput, + PermissionLevel: permission.Everyone, + Category: command.General, + Arguments: command.Arguments( + command.NewRequiredAutocompleteableArgument("query", "The search term to find articles", interaction.OptionTypeString, i18n.MessageInvalidArgument, c.AutoCompleteHandler), + ), + DefaultEphemeral: true, + Timeout: time.Second * 7, + } +} + +func (c KBSearchCommand) GetExecutor() interface{} { + return c.Execute +} + +func (KBSearchCommand) Execute(ctx registry.CommandContext, query string) { + articles, err := dbclient.Client.KBArticles.Search(ctx, ctx.GuildId(), query, 5) + if err != nil { + ctx.HandleError(err) + return + } + + if _, err := ctx.ReplyWith(command.NewEphemeralMessageResponseWithComponents( + BuildArticleList(ctx, ctx.GetMessage(i18n.MessageKbSearchResults), articles, SourceSearch), + )); err != nil { + ctx.HandleError(err) + } +} + +func (KBSearchCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { + if value == "" { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) + defer cancel() + + articles, err := dbclient.Client.KBArticles.SearchContaining(ctx, data.GuildId.Value, value, 25) + if err != nil { + sentry.Error(err) + return nil + } + + choices := make([]interaction.ApplicationCommandOptionChoice, len(articles)) + for i, article := range articles { + name := article.Title + if len(name) > 100 { + name = name[:100] + } + + choices[i] = interaction.ApplicationCommandOptionChoice{ + Name: name, + Value: fmt.Sprintf("%d", article.Id), + } + } + + return choices +} diff --git a/bot/command/impl/kb/send.go b/bot/command/impl/kb/send.go new file mode 100644 index 00000000..106fcc16 --- /dev/null +++ b/bot/command/impl/kb/send.go @@ -0,0 +1,100 @@ +package kb + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/TicketsBot-cloud/common/permission" + "github.com/TicketsBot-cloud/common/sentry" + "github.com/TicketsBot-cloud/gdl/objects/interaction" + "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/i18n" +) + +type KBSendCommand struct { +} + +func (c KBSendCommand) Properties() registry.Properties { + return registry.Properties{ + Name: "send", + Description: i18n.HelpKbSend, + Type: interaction.ApplicationCommandTypeChatInput, + PermissionLevel: permission.Support, + Category: command.General, + Arguments: command.Arguments( + command.NewRequiredAutocompleteableArgument("article", "The article to send", interaction.OptionTypeString, i18n.MessageInvalidArgument, c.AutoCompleteHandler), + ), + Timeout: time.Second * 7, + } +} + +func (c KBSendCommand) GetExecutor() interface{} { + return c.Execute +} + +func (KBSendCommand) Execute(ctx registry.CommandContext, articleIdStr string) { + articleId, err := strconv.Atoi(articleIdStr) + if err != nil { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageInvalidArgument) + return + } + + article, ok, err := dbclient.Client.KBArticles.Get(ctx, articleId) + if err != nil { + ctx.HandleError(err) + return + } + + if !ok || article.GuildId != ctx.GuildId() || !article.Published { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageKbArticleNotFound) + return + } + + // TODO(review): custom-embed articles render as plain V2; full embed translation + // deferred. Articles carrying a stored custom embed still have their title, content + // and image rendered via BuildArticleView, so nothing is silently lost. + // + // SourcePublic omits navigation buttons: this is a public message any member can + // see, so it must not carry interactive Back controls. + if _, err := ctx.ReplyWith(command.NewMessageResponseWithComponents( + BuildArticleView(ctx, article, SourcePublic, false), + )); err != nil { + ctx.HandleError(err) + return + } +} + +func (KBSendCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { + if value == "" { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) + defer cancel() + + articles, err := dbclient.Client.KBArticles.SearchContaining(ctx, data.GuildId.Value, value, 25) + if err != nil { + sentry.Error(err) + return nil + } + + choices := make([]interaction.ApplicationCommandOptionChoice, len(articles)) + for i, article := range articles { + name := article.Title + if len(name) > 100 { + name = name[:100] + } + + choices[i] = interaction.ApplicationCommandOptionChoice{ + Name: name, + Value: fmt.Sprintf("%d", article.Id), + } + } + + return choices +} diff --git a/bot/command/impl/settings/removeadmin.go b/bot/command/impl/settings/removeadmin.go index b6636e64..0945149b 100644 --- a/bot/command/impl/settings/removeadmin.go +++ b/bot/command/impl/settings/removeadmin.go @@ -48,12 +48,6 @@ func (c RemoveAdminCommand) Execute(ctx registry.CommandContext, id uint64) { Inline: false, } - settings, err := ctx.Settings() - if err != nil { - ctx.HandleError(err) - return - } - mentionableType, valid := context.DetermineMentionableType(ctx, id) if !valid { ctx.ReplyWithFields(customisation.Red, i18n.Error, i18n.MessageRemoveAdminNoMembers, utils.ToSlice(usageEmbed)) @@ -113,12 +107,11 @@ func (c RemoveAdminCommand) Execute(ctx registry.CommandContext, id uint64) { utils.BuildEmbed(ctx, customisation.Green, i18n.TitleRemoveAdmin, i18n.MessageRemoveAdminSuccess, nil, mention), )) - // Remove user / role from thread notification channel - if settings.TicketNotificationChannel != nil { + // Remove user / role from each panel's thread notification channel + { member, err := ctx.Member() auditReason := "Removed admin member/role" - // Get the name of the user/role being removed var targetName string if mentionableType == context.MentionableTypeUser { if targetMember, err := ctx.Worker().GetGuildMember(ctx.GuildId(), id); err == nil { @@ -141,12 +134,24 @@ func (c RemoveAdminCommand) Execute(ctx registry.CommandContext, id uint64) { auditReason = fmt.Sprintf("Removed admin member/role by %s", member.User.Username) } - reasonCtx := request.WithAuditReason(ctx, auditReason) - _ = ctx.Worker().EditChannelPermissions(reasonCtx, *settings.TicketNotificationChannel, channel.PermissionOverwrite{ - Id: id, - Type: mentionableType.OverwriteType(), - Allow: 0, - Deny: permission.BuildPermissions(permission.ViewChannel), - }) + panels, _ := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) + seen := make(map[uint64]struct{}) + for _, p := range panels { + if p.TicketNotificationChannel == nil { + continue + } + chId := *p.TicketNotificationChannel + if _, already := seen[chId]; already { + continue + } + seen[chId] = struct{}{} + reasonCtx := request.WithAuditReason(ctx, auditReason) + _ = ctx.Worker().EditChannelPermissions(reasonCtx, chId, channel.PermissionOverwrite{ + Id: id, + Type: mentionableType.OverwriteType(), + Allow: 0, + Deny: permission.BuildPermissions(permission.ViewChannel), + }) + } } } diff --git a/bot/command/impl/settings/removesupport.go b/bot/command/impl/settings/removesupport.go index 39b7345f..ac007795 100644 --- a/bot/command/impl/settings/removesupport.go +++ b/bot/command/impl/settings/removesupport.go @@ -49,12 +49,6 @@ func (c RemoveSupportCommand) Execute(ctx registry.CommandContext, id uint64) { Inline: false, } - settings, err := ctx.Settings() - if err != nil { - ctx.HandleError(err) - return - } - mentionableType, valid := context.DetermineMentionableType(ctx, id) if !valid { ctx.ReplyWithFields(customisation.Red, i18n.Error, i18n.MessageRemoveSupportNoMembers, utils.ToSlice(usageEmbed)) @@ -124,12 +118,11 @@ func (c RemoveSupportCommand) Execute(ctx registry.CommandContext, id uint64) { utils.BuildEmbed(ctx, customisation.Green, i18n.TitleRemoveSupport, i18n.MessageRemoveSupportSuccess, nil, mention), )) - // Remove user / role from thread notification channel - if settings.TicketNotificationChannel != nil { + // Remove user / role from each panel's thread notification channel + { member, err := ctx.Member() auditReason := "Removed support member/role" - // Get the name of the user/role being removed var targetName string if mentionableType == context.MentionableTypeUser { if targetMember, err := ctx.Worker().GetGuildMember(ctx.GuildId(), id); err == nil { @@ -152,12 +145,24 @@ func (c RemoveSupportCommand) Execute(ctx registry.CommandContext, id uint64) { auditReason = fmt.Sprintf("Removed support member/role by %s", member.User.Username) } - reasonCtx := request.WithAuditReason(ctx, auditReason) - _ = ctx.Worker().EditChannelPermissions(reasonCtx, *settings.TicketNotificationChannel, channel.PermissionOverwrite{ - Id: id, - Type: mentionableType.OverwriteType(), - Allow: 0, - Deny: permission.BuildPermissions(permission.ViewChannel), - }) + panels, _ := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) + seen := make(map[uint64]struct{}) + for _, p := range panels { + if p.TicketNotificationChannel == nil { + continue + } + chId := *p.TicketNotificationChannel + if _, already := seen[chId]; already { + continue + } + seen[chId] = struct{}{} + reasonCtx := request.WithAuditReason(ctx, auditReason) + _ = ctx.Worker().EditChannelPermissions(reasonCtx, chId, channel.PermissionOverwrite{ + Id: id, + Type: mentionableType.OverwriteType(), + Allow: 0, + Deny: permission.BuildPermissions(permission.ViewChannel), + }) + } } } diff --git a/bot/command/impl/settings/setup/auto.go b/bot/command/impl/settings/setup.go similarity index 86% rename from bot/command/impl/settings/setup/auto.go rename to bot/command/impl/settings/setup.go index b3a890c6..f2c8f47e 100644 --- a/bot/command/impl/settings/setup/auto.go +++ b/bot/command/impl/settings/setup.go @@ -1,4 +1,4 @@ -package setup +package settings import ( "context" @@ -21,30 +21,26 @@ import ( "github.com/TicketsBot-cloud/worker/i18n" ) -const freePanelLimit = 3 +type SetupCommand struct{} -type AutoSetupCommand struct { -} - -func (AutoSetupCommand) Properties() registry.Properties { +func (SetupCommand) Properties() registry.Properties { return registry.Properties{ - Name: "auto", + Name: "setup", Description: i18n.HelpSetup, Type: interaction.ApplicationCommandTypeChatInput, PermissionLevel: permission.Admin, Category: command.Settings, - Children: nil, InteractionOnly: true, Timeout: time.Second * 10, } } -func (c AutoSetupCommand) GetExecutor() interface{} { +func (c SetupCommand) GetExecutor() interface{} { return c.Execute } // TODO: Separate into diff functions -func (AutoSetupCommand) Execute(ctx registry.CommandContext) { +func (SetupCommand) Execute(ctx registry.CommandContext) { interaction, ok := ctx.(*cmdcontext.SlashCommandContext) if !ok { return @@ -93,10 +89,6 @@ func (AutoSetupCommand) Execute(ctx registry.CommandContext) { switch transcriptChannel, err := ctx.Worker().CreateGuildChannel(context.Background(), ctx.GuildId(), getTranscriptChannelData(ctx.GuildId(), supportRoleId, adminRoleId)); err { case nil: messageContent += fmt.Sprintf("\n✅ %s", i18n.GetMessageFromGuild(ctx.GuildId(), i18n.SetupAutoTranscriptChannelSuccess, transcriptChannel.Id)) - - if err := dbclient.Client.ArchiveChannel.Set(ctx, ctx.GuildId(), utils.Ptr(transcriptChannel.Id)); err != nil { - ctx.HandleError(err) - } default: failed = true messageContent += fmt.Sprintf("\n❌ %s", i18n.GetMessageFromGuild(ctx.GuildId(), i18n.SetupAutoTranscriptChannelFailure)) @@ -115,27 +107,19 @@ func (AutoSetupCommand) Execute(ctx registry.CommandContext) { Type: channel.ChannelTypeGuildCategory, } - switch category, err := ctx.Worker().CreateGuildChannel(context.Background(), ctx.GuildId(), categoryData); err { - case nil: // ok + switch _, err := ctx.Worker().CreateGuildChannel(context.Background(), ctx.GuildId(), categoryData); err { + case nil: messageContent += fmt.Sprintf("\n✅ %s", i18n.GetMessageFromGuild(ctx.GuildId(), i18n.SetupAutoCategorySuccess)) - - if err := dbclient.Client.ChannelCategory.Set(ctx, ctx.GuildId(), category.Id); err != nil { - ctx.HandleError(err) - } - default: // error + default: messageContent += fmt.Sprintf("\n❌ %s", i18n.GetMessageFromGuild(ctx.GuildId(), i18n.SetupAutoCategoryFailure)) } messageContent += fmt.Sprintf("\n\n%s", i18n.GetMessageFromGuild(ctx.GuildId(), i18n.SetupAutoCompleted, fmt.Sprintf("%s/manage/%d/panels", config.Conf.Bot.DashboardUrl, ctx.GuildId()), adminRoleId, supportRoleId)) messageContent += fmt.Sprintf("\n\n%s", i18n.GetMessageFromGuild(ctx.GuildId(), i18n.SetupAutoDocs, config.Conf.Bot.DocsUrl)) - // update status if shouldEdit { embed.SetDescription(messageContent) - - if err := edit(interaction, embed); err != nil { - shouldEdit = false - } + edit(interaction, embed) //nolint:errcheck } } @@ -156,7 +140,6 @@ func getColour(ctx context.Context, guildId uint64, failed bool) int { colour = customisation.Green } - // ignore error, return default hex, _ := customisation.GetColour(ctx, guildId, colour) return hex } @@ -171,7 +154,7 @@ func getTranscriptChannelData(guildId, supportRoleId, adminRoleId uint64) rest.C ) overwrites := []channel.PermissionOverwrite{ - { // deny everyone else access to channel + { Id: guildId, Type: channel.PermissionTypeRole, Allow: 0, diff --git a/bot/command/impl/settings/setup/limit.go b/bot/command/impl/settings/setup/limit.go deleted file mode 100644 index 87cc61bb..00000000 --- a/bot/command/impl/settings/setup/limit.go +++ /dev/null @@ -1,47 +0,0 @@ -package setup - -import ( - "time" - - "github.com/TicketsBot-cloud/common/permission" - "github.com/TicketsBot-cloud/gdl/objects/interaction" - "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/i18n" -) - -type LimitSetupCommand struct{} - -func (LimitSetupCommand) Properties() registry.Properties { - return registry.Properties{ - Name: "limit", - Description: i18n.HelpSetup, - Type: interaction.ApplicationCommandTypeChatInput, - PermissionLevel: permission.Admin, - Category: command.Settings, - Arguments: command.Arguments( - command.NewRequiredArgument("limit", "The maximum amount of tickets a user can have open simultaneously", interaction.OptionTypeInteger, i18n.SetupLimitInvalid), - ), - Timeout: time.Second * 3, - } -} - -func (c LimitSetupCommand) GetExecutor() interface{} { - return c.Execute -} - -func (LimitSetupCommand) Execute(ctx registry.CommandContext, limit int) { - if limit < 1 || limit > 10 { - ctx.Reply(customisation.Red, i18n.TitleSetup, i18n.SetupLimitInvalid) - return - } - - if err := dbclient.Client.TicketLimit.Set(ctx, ctx.GuildId(), uint8(limit)); err != nil { - ctx.HandleError(err) - return - } - - ctx.Reply(customisation.Green, i18n.TitleSetup, i18n.SetupLimitComplete, limit) -} diff --git a/bot/command/impl/settings/setup/setup.go b/bot/command/impl/settings/setup/setup.go deleted file mode 100644 index 24c7e9ec..00000000 --- a/bot/command/impl/settings/setup/setup.go +++ /dev/null @@ -1,82 +0,0 @@ -package setup - -import ( - "github.com/TicketsBot-cloud/common/permission" - "github.com/TicketsBot-cloud/gdl/objects/interaction" - "github.com/TicketsBot-cloud/worker/bot/command" - "github.com/TicketsBot-cloud/worker/bot/command/registry" - "github.com/TicketsBot-cloud/worker/i18n" -) - -type SetupCommand struct { -} - -func (SetupCommand) Properties() registry.Properties { - return registry.Properties{ - Name: "setup", - Description: i18n.HelpSetup, - Type: interaction.ApplicationCommandTypeChatInput, - PermissionLevel: permission.Admin, - Category: command.Settings, - Children: []registry.Command{ - AutoSetupCommand{}, - LimitSetupCommand{}, - TranscriptsSetupCommand{}, - ThreadsSetupCommand{}, - }, - } -} - -func (c SetupCommand) GetExecutor() interface{} { - return c.Execute -} - -func (c SetupCommand) Execute(ctx registry.CommandContext) { - // Parent commands cannot be called - //ctx.ReplyWithFieldsPermanent(customisation.Green, i18n.TitleSetup, i18n.SetupChoose, c.buildFields(ctx)) -} - -/* TODO: Remove -func (SetupCommand) buildFields(ctx registry.CommandContext) []embed.EmbedField { - fields := make([]embed.EmbedField, 9) - - group, _ := errgroup.WithContext(context.Background()) - - group.Go(getFieldFunc(ctx, fields, 0, "/setup auto", i18n.SetupAutoDescription, true)) - group.Go(getFieldFunc(ctx, fields, 1, "Dashboard", i18n.SetupDashboardDescription, true)) - fields[2] = embed.EmbedField{ - Name: "\u200b", - Value: "‎", - Inline: true, - } - group.Go(getFieldFunc(ctx, fields, 3, "/setup prefix", i18n.SetupPrefixDescription, true)) - group.Go(getFieldFunc(ctx, fields, 4, "/setup limit", i18n.SetupLimitDescription, true)) - group.Go(getFieldFunc(ctx, fields, 5, "/setup welcomemessage", i18n.SetupWelcomeMessageDescription, false)) - group.Go(getFieldFunc(ctx, fields, 6, "/setup transcripts", i18n.SetupTranscriptsDescription, true)) - group.Go(getFieldFunc(ctx, fields, 7, "/setup category", i18n.SetupCategoryDescription, true)) - group.Go(getFieldFunc(ctx, fields, 8, "Reaction Panels", i18n.SetupReactionPanelsDescription, false, ctx.GuildId)) - - // should never happen - if err := group.Wait(); err != nil { - sentry.Error(err) - return nil - } - - return fields -} - -func newFieldFromTranslation(ctx registry.CommandContext, name string, value i18n.MessageId, inline bool, format ...interface{}) embed.EmbedField { - return embed.EmbedField{ - Name: name, - Value: i18n.GetMessageFromGuild(ctx.GuildId(), value, format...), - Inline: inline, - } -} - -func getFieldFunc(ctx registry.CommandContext, fields []embed.EmbedField, index int, name string, value i18n.MessageId, inline bool, format ...interface{}) func() error { - return func() error { - fields[index] = newFieldFromTranslation(ctx, name, value, inline, format...) - return nil - } -} -*/ diff --git a/bot/command/impl/settings/setup/threads.go b/bot/command/impl/settings/setup/threads.go deleted file mode 100644 index eaaa29ef..00000000 --- a/bot/command/impl/settings/setup/threads.go +++ /dev/null @@ -1,70 +0,0 @@ -package setup - -import ( - "time" - - "github.com/TicketsBot-cloud/common/permission" - "github.com/TicketsBot-cloud/gdl/objects/channel" - "github.com/TicketsBot-cloud/gdl/objects/interaction" - "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/i18n" -) - -type ThreadsSetupCommand struct{} - -func (ThreadsSetupCommand) Properties() registry.Properties { - return registry.Properties{ - Name: "use-threads", - Description: i18n.HelpSetup, - Type: interaction.ApplicationCommandTypeChatInput, - PermissionLevel: permission.Admin, - Category: command.Settings, - Arguments: command.Arguments( - command.NewRequiredArgument("use_threads", "Whether or not private threads should be used for ticket", interaction.OptionTypeBoolean, "infallible"), - command.NewOptionalArgument("ticket_notification_channel", "The channel that ticket open notifications should be sent to", interaction.OptionTypeChannel, "infallible"), - ), - InteractionOnly: true, - Timeout: time.Second * 5, - } -} - -func (c ThreadsSetupCommand) GetExecutor() interface{} { - return c.Execute -} - -func (ThreadsSetupCommand) Execute(ctx registry.CommandContext, useThreads bool, channelId *uint64) { - if useThreads { - if channelId == nil { - ctx.Reply(customisation.Red, i18n.Error, i18n.SetupThreadsNoNotificationChannel) - return - } - - ch, err := ctx.Worker().GetChannel(*channelId) - if err != nil { - ctx.HandleError(err) - return - } - - if ch.Type != channel.ChannelTypeGuildText { - ctx.Reply(customisation.Red, i18n.Error, i18n.SetupThreadsNotificationChannelType) - return - } - - if err := dbclient.Client.Settings.EnableThreads(ctx, ctx.GuildId(), *channelId); err != nil { - ctx.HandleError(err) - return - } - - ctx.Reply(customisation.Green, i18n.TitleSetup, i18n.SetupThreadsSuccess) - } else { - if err := dbclient.Client.Settings.DisableThreads(ctx, ctx.GuildId()); err != nil { - ctx.HandleError(err) - return - } - - ctx.Reply(customisation.Green, i18n.TitleSetup, i18n.SetupThreadsDisabled) - } -} diff --git a/bot/command/impl/settings/setup/transcripts.go b/bot/command/impl/settings/setup/transcripts.go deleted file mode 100644 index 576e1c37..00000000 --- a/bot/command/impl/settings/setup/transcripts.go +++ /dev/null @@ -1,54 +0,0 @@ -package setup - -import ( - "time" - - "github.com/TicketsBot-cloud/common/permission" - "github.com/TicketsBot-cloud/gdl/objects/interaction" - "github.com/TicketsBot-cloud/gdl/rest/request" - "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/utils" - "github.com/TicketsBot-cloud/worker/i18n" -) - -type TranscriptsSetupCommand struct{} - -func (TranscriptsSetupCommand) Properties() registry.Properties { - return registry.Properties{ - Name: "transcripts", - Description: i18n.HelpSetup, - Type: interaction.ApplicationCommandTypeChatInput, - Aliases: []string{"transcript", "archives", "archive"}, - PermissionLevel: permission.Admin, - Category: command.Settings, - Arguments: command.Arguments( - command.NewRequiredArgument("channel", "The channel that ticket transcripts should be sent to", interaction.OptionTypeChannel, i18n.SetupTranscriptsInvalid), - ), - Timeout: time.Second * 5, - } -} - -func (c TranscriptsSetupCommand) GetExecutor() interface{} { - return c.Execute -} - -func (TranscriptsSetupCommand) Execute(ctx registry.CommandContext, channelId uint64) { - if _, err := ctx.Worker().GetChannel(channelId); err != nil { - if restError, ok := err.(request.RestError); ok && restError.IsClientError() { - ctx.Reply(customisation.Red, i18n.Error, i18n.SetupTranscriptsInvalid, ctx.ChannelId, "/setup transcripts #logs") - } else { - ctx.HandleError(err) - } - - return - } - - if err := dbclient.Client.ArchiveChannel.Set(ctx, ctx.GuildId(), utils.Ptr(channelId)); err == nil { - ctx.Reply(customisation.Green, i18n.TitleSetup, i18n.SetupTranscriptsComplete, channelId) - } else { - ctx.HandleError(err) - } -} diff --git a/bot/command/impl/statistics/statsserver.go b/bot/command/impl/statistics/statsserver.go index a6f38a1b..f1ba1629 100644 --- a/bot/command/impl/statistics/statsserver.go +++ b/bot/command/impl/statistics/statsserver.go @@ -6,17 +6,14 @@ import ( "strings" "time" - "github.com/TicketsBot-cloud/analytics-client" "github.com/TicketsBot-cloud/common/permission" - "github.com/TicketsBot-cloud/gdl/objects/channel/embed" + "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/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/utils" - "github.com/TicketsBot-cloud/worker/experiments" "github.com/TicketsBot-cloud/worker/i18n" "github.com/getsentry/sentry-go" "github.com/jedib0t/go-pretty/v6/table" @@ -54,12 +51,16 @@ func (StatsServerCommand) Execute(ctx registry.CommandContext) { var totalTickets, openTickets uint64 // totalTickets - group.Go(func() (err error) { + group.Go(func() error { span := sentry.StartSpan(span.Context(), "GetTotalTicketCount") defer span.Finish() - totalTickets, err = dbclient.Analytics.GetTotalTicketCount(ctx, ctx.GuildId()) - return + count, err := dbclient.Client.Tickets.GetTotalTicketCount(ctx, ctx.GuildId()) + if err != nil { + return err + } + totalTickets = uint64(count) + return nil }) // openTickets @@ -79,49 +80,54 @@ func (StatsServerCommand) Execute(ctx registry.CommandContext) { var feedbackRating float64 var feedbackCount uint64 - group.Go(func() (err error) { + group.Go(func() error { span := sentry.StartSpan(span.Context(), "GetAverageFeedbackRating") defer span.Finish() - feedbackRating, err = dbclient.Analytics.GetAverageFeedbackRatingGuild(ctx, ctx.GuildId()) - return + avg, err := dbclient.Client.ServiceRatings.GetAverage(ctx, ctx.GuildId()) + if err != nil { + return err + } + feedbackRating = float64(avg) + return nil }) - group.Go(func() (err error) { + group.Go(func() error { span := sentry.StartSpan(span.Context(), "GetFeedbackCount") defer span.Finish() - feedbackCount, err = dbclient.Analytics.GetFeedbackCountGuild(ctx, ctx.GuildId()) - return + count, err := dbclient.Client.ServiceRatings.GetCount(ctx, ctx.GuildId()) + if err != nil { + return err + } + feedbackCount = uint64(count) + return nil }) - // first response times - var firstResponseTime analytics.TripleWindow + var firstResponseTime database.TripleWindow group.Go(func() (err error) { span := sentry.StartSpan(span.Context(), "GetFirstResponseTimeStats") defer span.Finish() - firstResponseTime, err = dbclient.Analytics.GetFirstResponseTimeStats(ctx, ctx.GuildId()) + firstResponseTime, err = dbclient.Client.FirstResponseTime.GetAverageTripleWindow(ctx, ctx.GuildId()) return }) - // ticket duration - var ticketDuration analytics.TripleWindow + var ticketDuration database.TripleWindow group.Go(func() (err error) { span := sentry.StartSpan(span.Context(), "GetTicketDurationStats") defer span.Finish() - ticketDuration, err = dbclient.Analytics.GetTicketDurationStats(ctx, ctx.GuildId()) + ticketDuration, err = dbclient.Client.Tickets.GetTicketDurationTripleWindow(ctx, ctx.GuildId()) return }) - // tickets per day var ticketVolumeTable string group.Go(func() error { span := sentry.StartSpan(span.Context(), "GetLastNTicketsPerDayGuild") defer span.Finish() - counts, err := dbclient.Analytics.GetLastNTicketsPerDayGuild(ctx, ctx.GuildId(), 7) + counts, err := dbclient.Client.Tickets.GetTicketsPerDay(ctx, ctx.GuildId(), 7) if err != nil { return err } @@ -139,6 +145,60 @@ func (StatsServerCommand) Execute(ctx registry.CommandContext) { return nil }) + var feedbackDist [5]int + group.Go(func() (err error) { + span := sentry.StartSpan(span.Context(), "GetFeedbackDistribution") + defer span.Finish() + + feedbackDist, err = dbclient.Client.ServiceRatings.GetDistribution(ctx, ctx.GuildId()) + return + }) + + var feedbackRate database.FeedbackResponseRate + group.Go(func() (err error) { + span := sentry.StartSpan(span.Context(), "GetFeedbackResponseRate") + defer span.Finish() + + feedbackRate, err = dbclient.Client.ServiceRatings.GetResponseRate(ctx, ctx.GuildId(), 30) + return + }) + + var autoCloseStats database.AutoCloseStats + group.Go(func() (err error) { + span := sentry.StartSpan(span.Context(), "GetAutoCloseVsManualClose") + defer span.Finish() + + autoCloseStats, err = dbclient.Client.CloseReason.GetAutoCloseVsManualClose(ctx, ctx.GuildId(), 30) + return + }) + + var threadSplit database.ThreadChannelSplit + group.Go(func() (err error) { + span := sentry.StartSpan(span.Context(), "GetThreadChannelSplit") + defer span.Finish() + + threadSplit, err = dbclient.Client.Tickets.GetThreadChannelSplit(ctx, ctx.GuildId(), 30) + return + }) + + var oneTouchResolution database.OneTouchResolution + group.Go(func() (err error) { + span := sentry.StartSpan(span.Context(), "GetOneTouchResolutionRate") + defer span.Finish() + + oneTouchResolution, err = dbclient.Client.TicketMessageCounts.GetOneTouchResolutionRate(ctx, ctx.GuildId(), 30) + return + }) + + var avgMessageCounts database.AverageMessageCounts + group.Go(func() (err error) { + span := sentry.StartSpan(span.Context(), "GetAverageMessageCounts") + defer span.Finish() + + avgMessageCounts, err = dbclient.Client.TicketMessageCounts.GetAverageMessageCounts(ctx, ctx.GuildId(), 30) + return + }) + if err := group.Wait(); err != nil { ctx.HandleError(err) return @@ -146,105 +206,149 @@ func (StatsServerCommand) Execute(ctx registry.CommandContext) { span = sentry.StartSpan(span.Context(), "Send Message") - if experiments.HasFeature(ctx, ctx.GuildId(), experiments.COMPONENTS_V2_STATISTICS) { - guildData, err := ctx.Guild() - if err != nil { - ctx.HandleError(err) - return - } + guildData, err := ctx.Guild() + if err != nil { + ctx.HandleError(err) + return + } - mainStats := []string{ - fmt.Sprintf("**Total Tickets**: %d", totalTickets), - fmt.Sprintf("**Open Tickets**: %d", openTickets), - fmt.Sprintf("**Feedback Rating**: %.1f / 5 ★", feedbackRating), - fmt.Sprintf("**Feedback Count**: %d", feedbackCount), - } + mainStats := []string{ + fmt.Sprintf("**Total Tickets**: %d", totalTickets), + fmt.Sprintf("**Open Tickets**: %d", openTickets), + fmt.Sprintf("**Feedback Rating**: %.1f / 5 ★", feedbackRating), + fmt.Sprintf("**Feedback Count**: %d", feedbackCount), + } - responseTimeStats := []string{ - fmt.Sprintf("**Total**: %s", formatNullableTime(firstResponseTime.AllTime)), - fmt.Sprintf("**Monthly**: %s", formatNullableTime(firstResponseTime.Monthly)), - fmt.Sprintf("**Weekly**: %s", formatNullableTime(firstResponseTime.Weekly)), - } + responseTimeStats := []string{ + fmt.Sprintf("**Total**: %s", formatNullableTime(firstResponseTime.AllTime)), + fmt.Sprintf("**Monthly**: %s", formatNullableTime(firstResponseTime.Monthly)), + fmt.Sprintf("**Weekly**: %s", formatNullableTime(firstResponseTime.Weekly)), + } - ticketDurationStats := []string{ - fmt.Sprintf("**Total**: %s", formatNullableTime(ticketDuration.AllTime)), - fmt.Sprintf("**Monthly**: %s", formatNullableTime(ticketDuration.Monthly)), - fmt.Sprintf("**Weekly**: %s", formatNullableTime(ticketDuration.Weekly)), - } + ticketDurationStats := []string{ + fmt.Sprintf("**Total**: %s", formatNullableTime(ticketDuration.AllTime)), + fmt.Sprintf("**Monthly**: %s", formatNullableTime(ticketDuration.Monthly)), + fmt.Sprintf("**Weekly**: %s", formatNullableTime(ticketDuration.Weekly)), + } - var topSection []component.Component + var topSection []component.Component - iconUrl := guildData.IconUrl() - if iconUrl == "" { - topSection = []component.Component{ - component.BuildTextDisplay(component.TextDisplay{Content: "## Server Ticket Statistics"}), - component.BuildTextDisplay(component.TextDisplay{ - Content: fmt.Sprintf("● %s", strings.Join(mainStats, "\n● ")), - }), - } - } else { - topSection = []component.Component{ - component.BuildSection(component.Section{ - Accessory: component.BuildThumbnail(component.Thumbnail{ - Media: component.UnfurledMediaItem{ - Url: iconUrl, - }, - }), - Components: []component.Component{ - component.BuildTextDisplay(component.TextDisplay{Content: "## Server Ticket Statistics"}), - component.BuildTextDisplay(component.TextDisplay{ - Content: fmt.Sprintf("● %s", strings.Join(mainStats, "\n● ")), - }), + iconUrl := guildData.IconUrl() + if iconUrl == "" { + topSection = []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "## Server Ticket Statistics"}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("● %s", strings.Join(mainStats, "\n● ")), + }), + } + } else { + topSection = []component.Component{ + component.BuildSection(component.Section{ + Accessory: component.BuildThumbnail(component.Thumbnail{ + Media: component.UnfurledMediaItem{ + Url: iconUrl, }, }), - } + Components: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "## Server Ticket Statistics"}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("● %s", strings.Join(mainStats, "\n● ")), + }), + }, + }), } + } - innerComponents := append(topSection, []component.Component{ - component.BuildSeparator(component.Separator{}), - component.BuildTextDisplay(component.TextDisplay{ - Content: fmt.Sprintf("### Average Response Time\n● %s", strings.Join(responseTimeStats, "\n● ")), - }), - component.BuildSeparator(component.Separator{}), - component.BuildTextDisplay(component.TextDisplay{ - Content: fmt.Sprintf("### Average Ticket Duration\n● %s", strings.Join(ticketDurationStats, "\n● ")), - }), - component.BuildSeparator(component.Separator{}), - component.BuildTextDisplay(component.TextDisplay{ - Content: fmt.Sprintf( - "### Ticket Volume\n```\n%s\n```", - ticketVolumeTable, - ), - }), - }...) + autoCloseTotal := autoCloseStats.AutoClosed + autoCloseStats.ManualClosed + var autoClosePct, manualClosePct float64 + if autoCloseTotal > 0 { + autoClosePct = float64(autoCloseStats.AutoClosed) / float64(autoCloseTotal) * 100 + manualClosePct = float64(autoCloseStats.ManualClosed) / float64(autoCloseTotal) * 100 + } - ctx.ReplyWith(command.NewEphemeralMessageResponseWithComponents(utils.Slice(component.BuildContainer(component.Container{ - Components: innerComponents, - })))) - } else { - msgEmbed := embed.NewEmbed(). - SetTitle("Statistics"). - SetColor(ctx.GetColour(customisation.Green)). - AddField("Total Tickets", strconv.FormatUint(totalTickets, 10), true). - AddField("Open Tickets", strconv.FormatUint(openTickets, 10), true). - AddBlankField(true). - AddField("Feedback Rating", fmt.Sprintf("%.1f / 5 ⭐", feedbackRating), true). - AddField("Feedback Count", strconv.FormatUint(feedbackCount, 10), true). - AddBlankField(true). - AddField("Average First Response Time (Total)", formatNullableTime(firstResponseTime.AllTime), true). - AddField("Average First Response Time (Monthly)", formatNullableTime(firstResponseTime.Monthly), true). - AddField("Average First Response Time (Weekly)", formatNullableTime(firstResponseTime.Weekly), true). - AddField("Average Ticket Duration (Total)", formatNullableTime(ticketDuration.AllTime), true). - AddField("Average Ticket Duration (Monthly)", formatNullableTime(ticketDuration.Monthly), true). - AddField("Average Ticket Duration (Weekly)", formatNullableTime(ticketDuration.Weekly), true). - AddField("Ticket Volume", fmt.Sprintf("```\n%s\n```", ticketVolumeTable), false) - - _, _ = ctx.ReplyWith(command.NewEphemeralEmbedMessageResponse(msgEmbed)) + threadTotal := threadSplit.ThreadCount + threadSplit.ChannelCount + var threadPct, channelPct float64 + if threadTotal > 0 { + threadPct = float64(threadSplit.ThreadCount) / float64(threadTotal) * 100 + channelPct = float64(threadSplit.ChannelCount) / float64(threadTotal) * 100 + } + + feedbackDistStats := []string{ + fmt.Sprintf("**★1**: %d **★2**: %d **★3**: %d **★4**: %d **★5**: %d", + feedbackDist[0], feedbackDist[1], feedbackDist[2], feedbackDist[3], feedbackDist[4]), + fmt.Sprintf("**Response Rate**: %.0f%% (%d/%d tickets)", feedbackRate.Rate*100, feedbackRate.RatedTickets, feedbackRate.ClosedTickets), } + var oneTouchPct float64 + if oneTouchResolution.TotalClosed > 0 { + oneTouchPct = float64(oneTouchResolution.OneTouchCount) / float64(oneTouchResolution.TotalClosed) * 100 + } + + messageStats := []string{ + fmt.Sprintf("**One-Touch Resolution**: %.0f%% (%d/%d)", oneTouchPct, oneTouchResolution.OneTouchCount, oneTouchResolution.TotalClosed), + fmt.Sprintf("**Avg Staff Messages**: %s", formatNullableFloat(avgMessageCounts.AvgStaffMessages)), + fmt.Sprintf("**Avg User Messages**: %s", formatNullableFloat(avgMessageCounts.AvgUserMessages)), + fmt.Sprintf("**Avg Total Messages**: %s", formatNullableFloat(avgMessageCounts.AvgTotalMessages)), + } + + closureStats := []string{ + fmt.Sprintf("**Auto-closed**: %d (%.0f%%)", autoCloseStats.AutoClosed, autoClosePct), + fmt.Sprintf("**Manual**: %d (%.0f%%)", autoCloseStats.ManualClosed, manualClosePct), + } + + threadStats := []string{ + fmt.Sprintf("**Thread**: %d (%.0f%%)", threadSplit.ThreadCount, threadPct), + fmt.Sprintf("**Channel**: %d (%.0f%%)", threadSplit.ChannelCount, channelPct), + } + + innerComponents := append(topSection, []component.Component{ + component.BuildSeparator(component.Separator{}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("### Average Response Time\n● %s", strings.Join(responseTimeStats, "\n● ")), + }), + component.BuildSeparator(component.Separator{}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("### Average Ticket Duration\n● %s", strings.Join(ticketDurationStats, "\n● ")), + }), + component.BuildSeparator(component.Separator{}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("### Feedback Distribution\n● %s", strings.Join(feedbackDistStats, "\n● ")), + }), + component.BuildSeparator(component.Separator{}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("### Message Analytics\n● %s", strings.Join(messageStats, "\n● ")), + }), + component.BuildSeparator(component.Separator{}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("### Closure Method\n● %s", strings.Join(closureStats, "\n● ")), + }), + component.BuildSeparator(component.Separator{}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("### Thread / Channel Split\n● %s", strings.Join(threadStats, "\n● ")), + }), + component.BuildSeparator(component.Separator{}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf( + "### Ticket Volume\n```\n%s\n```", + ticketVolumeTable, + ), + }), + }...) + + ctx.ReplyWith(command.NewEphemeralMessageResponseWithComponents(utils.Slice(component.BuildContainer(component.Container{ + Components: innerComponents, + })))) + span.Finish() } func formatNullableTime(duration *time.Duration) string { return utils.FormatNullableTime(duration) } + +func formatNullableFloat(f *float64) string { + if f == nil { + return "N/A" + } + return fmt.Sprintf("%.1f", *f) +} diff --git a/bot/command/impl/statistics/statsuser.go b/bot/command/impl/statistics/statsuser.go index c6689a4a..2f318954 100644 --- a/bot/command/impl/statistics/statsuser.go +++ b/bot/command/impl/statistics/statsuser.go @@ -7,15 +7,12 @@ import ( "time" "github.com/TicketsBot-cloud/common/permission" - "github.com/TicketsBot-cloud/gdl/objects/channel/embed" "github.com/TicketsBot-cloud/gdl/objects/interaction" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "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/utils" - "github.com/TicketsBot-cloud/worker/experiments" "github.com/TicketsBot-cloud/worker/i18n" "github.com/getsentry/sentry-go" "golang.org/x/sync/errgroup" @@ -68,7 +65,6 @@ func (StatsUserCommand) Execute(ctx registry.CommandContext, userId uint64) { var isBlacklisted bool var totalTickets int var openTickets int - var ticketLimit uint8 group, _ := errgroup.WithContext(ctx) @@ -101,15 +97,6 @@ func (StatsUserCommand) Execute(ctx registry.CommandContext, userId uint64) { return err }) - // load ticketLimit - group.Go(func() (err error) { - span := sentry.StartSpan(span.Context(), "TicketLimit") - defer span.Finish() - - ticketLimit, err = dbclient.Client.TicketLimit.Get(ctx, ctx.GuildId()) - return - }) - if err := group.Wait(); err != nil { ctx.HandleError(err) return @@ -117,17 +104,45 @@ func (StatsUserCommand) Execute(ctx registry.CommandContext, userId uint64) { span := sentry.StartSpan(span.Context(), "Reply") - msgEmbed := embed.NewEmbed(). - SetTitle("Statistics"). - SetColor(ctx.GetColour(customisation.Green)). - SetAuthor(member.User.Username, "", member.User.AvatarUrl(256)). - AddField("Permission Level", "Regular", true). - AddField("Is Blacklisted", strconv.FormatBool(isBlacklisted), true). - AddBlankField(true). - AddField("Total Tickets", strconv.Itoa(totalTickets), true). - AddField("Open Tickets", fmt.Sprintf("%d / %d", openTickets, ticketLimit), true) - - _, _ = ctx.ReplyWith(command.NewEphemeralEmbedMessageResponse(msgEmbed)) + mainStats := []string{ + fmt.Sprintf("**Username**: %s", member.User.Username), + fmt.Sprintf("**Permission Level**: Regular"), + fmt.Sprintf("**Is Blacklisted**: %s", strconv.FormatBool(isBlacklisted)), + fmt.Sprintf("**Total Tickets**: %d", totalTickets), + fmt.Sprintf("**Open Tickets**: %d", openTickets), + } + + var topSection []component.Component + + avatarUrl := member.User.AvatarUrl(256) + if avatarUrl == "" { + topSection = []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "## Ticket User Statistics"}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("● %s", strings.Join(mainStats, "\n● ")), + }), + } + } else { + topSection = []component.Component{ + component.BuildSection(component.Section{ + Accessory: component.BuildThumbnail(component.Thumbnail{ + Media: component.UnfurledMediaItem{ + Url: avatarUrl, + }, + }), + Components: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "## Ticket User Statistics"}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("● %s", strings.Join(mainStats, "\n● ")), + }), + }, + }), + } + } + + ctx.ReplyWith(command.NewEphemeralMessageResponseWithComponents(utils.Slice(component.BuildContainer(component.Container{ + Components: topSection, + })))) span.Finish() } else { // Support rep stats group, _ := errgroup.WithContext(ctx) @@ -264,6 +279,15 @@ func (StatsUserCommand) Execute(ctx registry.CommandContext, userId uint64) { return }) + var openClaimedCount int + group.Go(func() (err error) { + span := sentry.StartSpan(span.Context(), "GetOpenClaimedCount") + defer span.Finish() + + openClaimedCount, err = dbclient.Client.TicketClaims.GetOpenClaimedCount(ctx, ctx.GuildId(), userId) + return + }) + if err := group.Wait(); err != nil { ctx.HandleError(err) return @@ -278,111 +302,91 @@ func (StatsUserCommand) Execute(ctx registry.CommandContext, userId uint64) { span := sentry.StartSpan(span.Context(), "Reply") - if experiments.HasFeature(ctx, ctx.GuildId(), experiments.COMPONENTS_V2_STATISTICS) { - userData, err := ctx.Worker().GetUser(userId) - if err != nil { - ctx.HandleError(err) - return - } - - mainStats := []string{ - fmt.Sprintf("**Username**: %s", userData.Username), - fmt.Sprintf("**Permission Level**: %s", permissionLevel), - fmt.Sprintf("**Feedback Rating**: %.1f / 5 ★", feedbackRating), - fmt.Sprintf("**Feedback Count**: %d", feedbackCount), - } + userData, err := ctx.Worker().GetUser(userId) + if err != nil { + ctx.HandleError(err) + return + } - responseTimeStats := []string{ - fmt.Sprintf("**Total**: %s", formatNullableTime(totalAR)), - fmt.Sprintf("**Monthly**: %s", formatNullableTime(monthlyAR)), - fmt.Sprintf("**Weekly**: %s", formatNullableTime(weeklyAR)), - } + mainStats := []string{ + fmt.Sprintf("**Username**: %s", userData.Username), + fmt.Sprintf("**Permission Level**: %s", permissionLevel), + fmt.Sprintf("**Feedback Rating**: %.1f / 5 ★", feedbackRating), + fmt.Sprintf("**Feedback Count**: %d", feedbackCount), + } - ticketsAnsweredStats := []string{ - fmt.Sprintf("**Total**: %d/%d", totalAnsweredTickets, totalTotalTickets), - fmt.Sprintf("**Monthly**: %d/%d", monthlyAnsweredTickets, monthlyTotalTickets), - fmt.Sprintf("**Weekly**: %d/%d", weeklyAnsweredTickets, weeklyTotalTickets), - } + responseTimeStats := []string{ + fmt.Sprintf("**Total**: %s", formatNullableTime(totalAR)), + fmt.Sprintf("**Monthly**: %s", formatNullableTime(monthlyAR)), + fmt.Sprintf("**Weekly**: %s", formatNullableTime(weeklyAR)), + } - claimedStats := []string{ - fmt.Sprintf("**Total**: %d", totalClaimedTickets), - fmt.Sprintf("**Monthly**: %d", monthlyClaimedTickets), - fmt.Sprintf("**Weekly**: %d", weeklyClaimedTickets), - } + ticketsAnsweredStats := []string{ + fmt.Sprintf("**Total**: %d/%d", totalAnsweredTickets, totalTotalTickets), + fmt.Sprintf("**Monthly**: %d/%d", monthlyAnsweredTickets, monthlyTotalTickets), + fmt.Sprintf("**Weekly**: %d/%d", weeklyAnsweredTickets, weeklyTotalTickets), + } - var topSection []component.Component + claimedStats := []string{ + fmt.Sprintf("**Total**: %d", totalClaimedTickets), + fmt.Sprintf("**Monthly**: %d", monthlyClaimedTickets), + fmt.Sprintf("**Weekly**: %d", weeklyClaimedTickets), + fmt.Sprintf("**Currently Open**: %d", openClaimedCount), + } - avatarUrl := member.User.AvatarUrl(256) - if avatarUrl == "" { - topSection = []component.Component{ - component.BuildTextDisplay(component.TextDisplay{Content: "## Ticket User Statistics"}), - component.BuildTextDisplay(component.TextDisplay{ - Content: fmt.Sprintf("● %s", strings.Join(mainStats, "\n● ")), - }), - } - } else { - topSection = []component.Component{ - component.BuildSection(component.Section{ - Accessory: component.BuildThumbnail(component.Thumbnail{ - Media: component.UnfurledMediaItem{ - Url: avatarUrl, - }, - }), - Components: []component.Component{ - component.BuildTextDisplay(component.TextDisplay{Content: "## Ticket User Statistics"}), - component.BuildTextDisplay(component.TextDisplay{ - Content: fmt.Sprintf("● %s", strings.Join(mainStats, "\n● ")), - }), - }, - }), - } - } + var topSection []component.Component - innerComponents := append(topSection, []component.Component{ - component.BuildSeparator(component.Separator{}), - component.BuildTextDisplay(component.TextDisplay{ - Content: fmt.Sprintf("### Average Response Time\n● %s", strings.Join(responseTimeStats, "\n● ")), - }), - component.BuildSeparator(component.Separator{}), + avatarUrl := member.User.AvatarUrl(256) + if avatarUrl == "" { + topSection = []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "## Ticket User Statistics"}), component.BuildTextDisplay(component.TextDisplay{ - Content: fmt.Sprintf( - "### Tickets Answered\n● %s", - strings.Join(ticketsAnsweredStats, "\n● "), - ), + Content: fmt.Sprintf("● %s", strings.Join(mainStats, "\n● ")), }), - component.BuildSeparator(component.Separator{}), - component.BuildTextDisplay(component.TextDisplay{ - Content: fmt.Sprintf( - "### Claimed Tickets\n● %s", - strings.Join(claimedStats, "\n● "), - ), - }), - }...) - - ctx.ReplyWith(command.NewEphemeralMessageResponseWithComponents(utils.Slice(component.BuildContainer(component.Container{ - Components: innerComponents, - })))) + } } else { - msgEmbed := embed.NewEmbed(). - SetTitle("Statistics"). - SetColor(ctx.GetColour(customisation.Green)). - SetAuthor(member.User.Username, "", member.User.AvatarUrl(256)). - AddField("Permission Level", permissionLevel, true). - AddField("Feedback Rating", fmt.Sprintf("%.1f / 5 ⭐ (%d ratings)", feedbackRating, feedbackCount), true). - AddBlankField(true). - AddField("Average First Response Time (Weekly)", formatNullableTime(weeklyAR), true). - AddField("Average First Response Time (Monthly)", formatNullableTime(monthlyAR), true). - AddField("Average First Response Time (Total)", formatNullableTime(totalAR), true). - AddField("Tickets Answered (Weekly)", fmt.Sprintf("%d / %d", weeklyAnsweredTickets, weeklyTotalTickets), true). - AddField("Tickets Answered (Monthly)", fmt.Sprintf("%d / %d", monthlyAnsweredTickets, monthlyTotalTickets), true). - AddField("Tickets Answered (Total)", fmt.Sprintf("%d / %d", totalAnsweredTickets, totalTotalTickets), true). - AddField("Claimed Tickets (Weekly)", strconv.Itoa(weeklyClaimedTickets), true). - AddField("Claimed Tickets (Monthly)", strconv.Itoa(monthlyClaimedTickets), true). - AddField("Claimed Tickets (Total)", strconv.Itoa(totalClaimedTickets), true) - - _, _ = ctx.ReplyWith(command.NewEphemeralEmbedMessageResponse(msgEmbed)) + topSection = []component.Component{ + component.BuildSection(component.Section{ + Accessory: component.BuildThumbnail(component.Thumbnail{ + Media: component.UnfurledMediaItem{ + Url: avatarUrl, + }, + }), + Components: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "## Ticket User Statistics"}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("● %s", strings.Join(mainStats, "\n● ")), + }), + }, + }), + } } + innerComponents := append(topSection, []component.Component{ + component.BuildSeparator(component.Separator{}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("### Average Response Time\n● %s", strings.Join(responseTimeStats, "\n● ")), + }), + component.BuildSeparator(component.Separator{}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf( + "### Tickets Answered\n● %s", + strings.Join(ticketsAnsweredStats, "\n● "), + ), + }), + component.BuildSeparator(component.Separator{}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf( + "### Claimed Tickets\n● %s", + strings.Join(claimedStats, "\n● "), + ), + }), + }...) + + ctx.ReplyWith(command.NewEphemeralMessageResponseWithComponents(utils.Slice(component.BuildContainer(component.Container{ + Components: innerComponents, + })))) + span.Finish() } } diff --git a/bot/command/impl/tags/tag.go b/bot/command/impl/tags/tag.go index 7e01eb96..9d7721cc 100644 --- a/bot/command/impl/tags/tag.go +++ b/bot/command/impl/tags/tag.go @@ -63,6 +63,20 @@ func (TagCommand) Execute(ctx registry.CommandContext, tagId string) { return } + // If the tag is linked to a KB article, override content with the article's content + if tag.KBArticleId != nil { + article, articleOk, articleErr := dbclient.Client.KBArticles.Get(ctx, *tag.KBArticleId) + if articleErr == nil && articleOk && article.Published { + if article.Content != nil { + tag.Content = article.Content + } + if article.Embed != nil { + tag.Embed = article.Embed + } + } + // If article not found or unpublished, fall through to the tag's own content + } + ticket, err := dbclient.Client.Tickets.GetByChannelAndGuild(ctx, ctx.ChannelId(), ctx.GuildId()) if err != nil { sentry.ErrorWithContext(err, ctx.ToErrorContext()) diff --git a/bot/command/impl/tickets/add.go b/bot/command/impl/tickets/add.go index f7edfdc6..254f1c01 100644 --- a/bot/command/impl/tickets/add.go +++ b/bot/command/impl/tickets/add.go @@ -4,6 +4,7 @@ import ( "fmt" permcache "github.com/TicketsBot-cloud/common/permission" + "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/interaction" "github.com/TicketsBot-cloud/gdl/rest/request" "github.com/TicketsBot-cloud/worker/bot/command" @@ -102,10 +103,14 @@ func (AddCommand) Execute(ctx registry.CommandContext, id uint64) { return } } else { - additionalPermissions, err := dbclient.Client.TicketPermissions.Get(ctx, ctx.GuildId()) - if err != nil { - ctx.HandleError(err) - return + var additionalPermissions database.TicketPermissions + if ticket.PanelId != nil { + var err error + additionalPermissions, err = dbclient.Client.PanelTicketPermissions.Get(ctx, *ticket.PanelId) + if err != nil { + ctx.HandleError(err) + return + } } // ticket.ChannelId cannot be nil, as we get by channel id @@ -132,10 +137,14 @@ func (AddCommand) Execute(ctx registry.CommandContext, id uint64) { } } else if mentionableType == context.MentionableTypeRole { // Handle role addition - additionalPermissions, err := dbclient.Client.TicketPermissions.Get(ctx, ctx.GuildId()) - if err != nil { - ctx.HandleError(err) - return + var additionalPermissions database.TicketPermissions + if ticket.PanelId != nil { + var err error + additionalPermissions, err = dbclient.Client.PanelTicketPermissions.Get(ctx, *ticket.PanelId) + if err != nil { + ctx.HandleError(err) + return + } } // ticket.ChannelId cannot be nil, as we get by channel id diff --git a/bot/command/impl/tickets/claim.go b/bot/command/impl/tickets/claim.go index 56e2949e..6dc46230 100644 --- a/bot/command/impl/tickets/claim.go +++ b/bot/command/impl/tickets/claim.go @@ -1,6 +1,7 @@ package tickets import ( + "errors" "fmt" "github.com/TicketsBot-cloud/common/permission" @@ -53,6 +54,12 @@ func (ClaimCommand) Execute(ctx registry.CommandContext) { } if err := logic.ClaimTicket(ctx, ctx, ticket, ctx.UserId()); err != nil { + var alreadyClaimed logic.AlreadyClaimedError + if errors.As(err, &alreadyClaimed) { + ctx.Reply(customisation.Red, i18n.Error, i18n.MessageAlreadyClaimed, fmt.Sprintf("<@%d>", alreadyClaimed.ClaimerId)) + return + } + ctx.HandleError(err) return } diff --git a/bot/command/impl/tickets/close.go b/bot/command/impl/tickets/close.go index b7ff8ee3..8e6748a8 100644 --- a/bot/command/impl/tickets/close.go +++ b/bot/command/impl/tickets/close.go @@ -64,14 +64,14 @@ func (CloseCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoC panelId = ticket.PanelId } - reasons, err = dbclient.Analytics.GetTopCloseReasons(ctx, data.GuildId.Value, panelId) + reasons, err = dbclient.Client.CloseReason.GetTopCloseReasons(ctx, data.GuildId.Value, panelId, 10) } else { var panelId *int if ticket.Id != 0 { panelId = ticket.PanelId } - reasons, err = dbclient.Analytics.GetTopCloseReasonsContaining(ctx, data.GuildId.Value, panelId, value) + reasons, err = dbclient.Client.CloseReason.GetTopCloseReasonsContaining(ctx, data.GuildId.Value, panelId, value, 10) } if err != nil { diff --git a/bot/command/impl/tickets/closerequest.go b/bot/command/impl/tickets/closerequest.go index 1c44679f..f8379892 100644 --- a/bot/command/impl/tickets/closerequest.go +++ b/bot/command/impl/tickets/closerequest.go @@ -82,17 +82,17 @@ func (CloseRequestCommand) Execute(ctx registry.CommandContext, closeDelay *int, return } - var messageId i18n.MessageId - var format []interface{} - if reason == nil { - messageId = i18n.MessageCloseRequestNoReason - format = []interface{}{ctx.UserId()} - } else { - messageId = i18n.MessageCloseRequestWithReason - format = []interface{}{ctx.UserId(), strings.ReplaceAll(*reason, "`", "\\`")} + msgEmbed := utils.BuildEmbed(ctx, customisation.Green, i18n.TitleCloseRequest, i18n.MessageCloseRequestIntro, nil, ctx.UserId()) + + if reason != nil { + msgEmbed.Description += fmt.Sprintf("\n\n**%s**\n```\n%s\n```", ctx.GetMessage(i18n.Reason), strings.ReplaceAll(*reason, "`", "\\`")) + } + + if closeAt != nil { + msgEmbed.Description += fmt.Sprintf("\n\n**%s**\n ()", ctx.GetMessage(i18n.MessageCloseRequestCloseAt), closeAt.Unix(), closeAt.Unix()) } - msgEmbed := utils.BuildEmbed(ctx, customisation.Green, i18n.TitleCloseRequest, messageId, nil, format...) + msgEmbed.Description += "\n\n" + ctx.GetMessage(i18n.MessageCloseRequestPrompt) components := component.BuildActionRow( component.BuildButton(component.Button{ Label: ctx.GetMessage(i18n.MessageCloseRequestAccept), diff --git a/bot/command/impl/tickets/oncall.go b/bot/command/impl/tickets/oncall.go index 099baf76..f4ca5d82 100644 --- a/bot/command/impl/tickets/oncall.go +++ b/bot/command/impl/tickets/oncall.go @@ -39,13 +39,21 @@ func (c OnCallCommand) GetExecutor() interface{} { } func (OnCallCommand) Execute(ctx registry.CommandContext) { - settings, err := ctx.Settings() + panels, err := dbclient.Client.Panel.GetByGuild(ctx, ctx.GuildId()) if err != nil { ctx.HandleError(err) return } - if !settings.UseThreads { + hasThreadPanel := false + for _, p := range panels { + if p.UseThreads { + hasThreadPanel = true + break + } + } + + if !hasThreadPanel { ctx.Reply(customisation.Red, i18n.Error, i18n.MessageOnCallChannelMode, "/on-call", fmt.Sprintf("%s/features/thread-mode", config.Conf.Bot.DocsUrl)) return } diff --git a/bot/command/impl/tickets/open.go b/bot/command/impl/tickets/open.go index 8ba0975e..3601c6bb 100644 --- a/bot/command/impl/tickets/open.go +++ b/bot/command/impl/tickets/open.go @@ -1,19 +1,27 @@ package tickets import ( + "context" + "errors" + "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/bot/button/handlers" "github.com/TicketsBot-cloud/worker/bot/command" - "github.com/TicketsBot-cloud/worker/bot/command/context" + cmdcontext "github.com/TicketsBot-cloud/worker/bot/command/context" "github.com/TicketsBot-cloud/worker/bot/command/registry" "github.com/TicketsBot-cloud/worker/bot/constants" "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" ) -type OpenCommand struct { -} +type OpenCommand struct{} func (OpenCommand) Properties() registry.Properties { return registry.Properties{ @@ -24,9 +32,10 @@ func (OpenCommand) Properties() registry.Properties { PermissionLevel: permission.Everyone, Category: command.Tickets, Arguments: command.Arguments( - command.NewOptionalArgument("subject", "The subject of the ticket", interaction.OptionTypeString, "infallible"), + command.NewRequiredAutocompleteableArgument("panel", "The panel to open a ticket with", interaction.OptionTypeString, i18n.MessageInvalidArgument, OpenCommand{}.AutoCompleteHandler), ), DefaultEphemeral: true, + DisableAutoDefer: true, Timeout: constants.TimeoutOpenTicket, } } @@ -35,22 +44,113 @@ func (c OpenCommand) GetExecutor() interface{} { return c.Execute } -func (OpenCommand) Execute(ctx *context.SlashCommandContext, providedSubject *string) { - settings, err := ctx.Settings() +func (OpenCommand) Execute(ctx *cmdcontext.SlashCommandContext, customId string) { + panel, ok, err := dbclient.Client.Panel.GetByCustomId(ctx, ctx.GuildId(), customId) + if err != nil { + ctx.HandleError(err) + return + } + + if !ok { + ctx.ReplyRaw(customisation.Red, "Error", "Panel not found.") + return + } + + openWithPanel(ctx, panel) +} + +func (OpenCommand) AutoCompleteHandler(data interaction.ApplicationCommandAutoCompleteInteraction, value string) []interaction.ApplicationCommandOptionChoice { + if data.GuildId.Value == 0 { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) + defer cancel() + + allPanels, err := dbclient.Client.Panel.GetByGuild(ctx, data.GuildId.Value) + if err != nil { + sentry.Error(err) + return nil + } + + var eligible []database.Panel + for _, p := range allPanels { + if p.ShowInOpenCommand && !p.Disabled && !p.ForceDisabled { + eligible = append(eligible, p) + } + } + + if value != "" { + var filtered []database.Panel + for _, p := range eligible { + if strings.Contains(strings.ToLower(p.Title), strings.ToLower(value)) { + filtered = append(filtered, p) + } + if len(filtered) == 25 { + break + } + } + eligible = filtered + } + + if len(eligible) > 25 { + eligible = eligible[:25] + } + + choices := make([]interaction.ApplicationCommandOptionChoice, len(eligible)) + for i, p := range eligible { + choices[i] = interaction.ApplicationCommandOptionChoice{ + Name: p.Title, + Value: p.CustomId, + } + } + return choices +} + +func openWithPanel(ctx *cmdcontext.SlashCommandContext, panel database.Panel) { + canProceed, outOfHoursTitle, outOfHoursWarning, outOfHoursColour, err := logic.ValidatePanelAccess(ctx, panel) + if err != nil { + ctx.HandleError(err) + return + } + + if !canProceed { + return + } + + if panel.FormId == nil { + logic.OpenTicket(ctx.Context, ctx, &panel, panel.Title, nil, outOfHoursTitle, outOfHoursWarning, outOfHoursColour, database.TicketSourceCommand) + return + } + + form, ok, err := dbclient.Client.Forms.Get(ctx, *panel.FormId) if err != nil { ctx.HandleError(err) return } - if settings.DisableOpenCommand { - ctx.Reply(customisation.Red, i18n.Error, i18n.MessageOpenCommandDisabled, "/open") + if !ok { + ctx.HandleError(errors.New("form not found")) + return + } + + inputs, err := dbclient.Client.FormInput.GetInputs(ctx, form.Id) + if err != nil { + ctx.HandleError(err) return } - var subject string - if providedSubject != nil { - subject = *providedSubject + inputOptions, err := dbclient.Client.FormInputOption.GetOptionsByForm(ctx, form.Id) + if err != nil { + ctx.HandleError(err) + return } - logic.OpenTicket(ctx.Context, ctx, nil, subject, nil, nil, nil, nil) + handlers.FetchApiOptions(ctx, form, panel, inputs, inputOptions) + + if len(inputs) == 0 { + logic.OpenTicket(ctx.Context, ctx, &panel, panel.Title, nil, outOfHoursTitle, outOfHoursWarning, outOfHoursColour, database.TicketSourceCommand) + } else { + ctx.Modal(handlers.BuildFormModal(panel, form, inputs, inputOptions)) + } } diff --git a/bot/command/impl/tickets/startticket.go b/bot/command/impl/tickets/startticket.go index b7a7c574..14803241 100644 --- a/bot/command/impl/tickets/startticket.go +++ b/bot/command/impl/tickets/startticket.go @@ -100,7 +100,7 @@ func (StartTicketCommand) Execute(ctx registry.CommandContext) { outOfHoursColour = colour } - ticket, err := logic.OpenTicket(ctx, interaction, panel, msg.Content, nil, outOfHoursTitle, outOfHoursWarning, outOfHoursColour) + ticket, err := logic.OpenTicket(ctx, interaction, panel, msg.Content, nil, outOfHoursTitle, outOfHoursWarning, outOfHoursColour, database.TicketSourceCommand) if err != nil { // Already handled return @@ -176,9 +176,12 @@ func addMessageSender(ctx registry.CommandContext, ticket database.Ticket, msg m } // Build permissions - additionalPermissions, err := dbclient.Client.TicketPermissions.Get(ctx, ctx.GuildId()) - if err != nil { - return err + var additionalPermissions database.TicketPermissions + if ticket.PanelId != nil { + additionalPermissions, err = dbclient.Client.PanelTicketPermissions.Get(ctx, *ticket.PanelId) + if err != nil { + return err + } } overwrite := logic.BuildUserOverwrite(msg.Author.Id, additionalPermissions) diff --git a/bot/command/impl/tickets/switchpanel.go b/bot/command/impl/tickets/switchpanel.go index 2800255f..ca043f79 100644 --- a/bot/command/impl/tickets/switchpanel.go +++ b/bot/command/impl/tickets/switchpanel.go @@ -217,12 +217,6 @@ func (SwitchPanelCommand) Execute(ctx *cmdcontext.SlashCommandContext, panelId i // If the ticket is a thread, we cannot update the permissions (possibly remove a small amount of members in the // future), or the parent channel (user may not have access to it. can you even move threads anyway?) if ticket.IsThread { - settings, err := ctx.Settings() - if err != nil { - ctx.HandleError(err) - return - } - data := rest.ModifyChannelData{} if shouldUpdateName { data.Name = newChannelName @@ -244,12 +238,7 @@ func (SwitchPanelCommand) Execute(ctx *cmdcontext.SlashCommandContext, panelId i // Modify join message if ticket.JoinMessageId != nil { - var notificationChannel *uint64 - if newPanel.TicketNotificationChannel != nil { - notificationChannel = newPanel.TicketNotificationChannel - } else if settings.TicketNotificationChannel != nil { - notificationChannel = settings.TicketNotificationChannel - } + notificationChannel := newPanel.TicketNotificationChannel if notificationChannel != nil { threadStaff, err := logic.GetStaffInThread(ctx.Context, ctx.Worker(), ticket, *ticket.ChannelId) diff --git a/bot/command/impl/tickets/transfer.go b/bot/command/impl/tickets/transfer.go index 945e4be2..2a71cafd 100644 --- a/bot/command/impl/tickets/transfer.go +++ b/bot/command/impl/tickets/transfer.go @@ -73,7 +73,13 @@ func (TransferCommand) Execute(ctx registry.CommandContext, userId uint64) { return } - if err := logic.ClaimTicket(ctx, ctx, ticket, userId); err != nil { + // Reassign the claim to the target user (transfer overwrites any existing claim) + if err := dbclient.Client.TicketClaims.Set(ctx, ticket.GuildId, ticket.Id, userId); err != nil { + ctx.HandleError(err) + return + } + + if err := logic.ApplyClaim(ctx, ctx, ticket, userId); err != nil { ctx.HandleError(err) return } diff --git a/bot/command/impl/tickets/unclaim.go b/bot/command/impl/tickets/unclaim.go index 422f7eb0..9f2f8d36 100644 --- a/bot/command/impl/tickets/unclaim.go +++ b/bot/command/impl/tickets/unclaim.go @@ -1,15 +1,8 @@ package tickets import ( - "fmt" - "github.com/TicketsBot-cloud/common/permission" - "github.com/TicketsBot-cloud/database" - "github.com/TicketsBot-cloud/gdl/objects/channel" "github.com/TicketsBot-cloud/gdl/objects/interaction" - discordpermission "github.com/TicketsBot-cloud/gdl/permission" - "github.com/TicketsBot-cloud/gdl/rest" - "github.com/TicketsBot-cloud/gdl/rest/request" "github.com/TicketsBot-cloud/worker/bot/command" "github.com/TicketsBot-cloud/worker/bot/command/context" "github.com/TicketsBot-cloud/worker/bot/command/registry" @@ -81,118 +74,8 @@ func (UnclaimCommand) Execute(ctx *context.SlashCommandContext) { return } - // Set to unclaimed in DB - if err := dbclient.Client.TicketClaims.Delete(ctx, ctx.GuildId(), ticket.Id); err != nil { - ctx.HandleError(err) - return - } - - // get panel - var panel *database.Panel - if ticket.PanelId != nil { - var derefPanel database.Panel - derefPanel, err = dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) - - if derefPanel.PanelId != 0 { - panel = &derefPanel - } - } - - // Use the actual ticket channel ID, not the current channel (which might be a notes thread) - ticketChannelId := *ticket.ChannelId - - // Get the channel to determine its parent category - ch, err := ctx.Worker().GetChannel(ticketChannelId) - if err != nil { - ctx.HandleError(err) - return - } - - overwrites, err := logic.CreateOverwrites(ctx.Context, ctx, ticket.UserId, panel, ch.ParentId.Value) - if err != nil { - ctx.HandleError(err) - return - } - - // Handle claimer access based on SwitchPanelClaimBehavior setting - claimSettings, err := dbclient.Client.ClaimSettings.Get(ctx, ctx.GuildId()) - if err != nil { - ctx.HandleError(err) - return - } - - if claimSettings.SwitchPanelClaimBehavior == database.SwitchPanelKeepAccess || - claimSettings.SwitchPanelClaimBehavior == database.SwitchPanelRemoveOnUnclaim { - - claimerHasAccess, err := logic.HasPermissionForPanel(ctx.Context, ctx.Worker(), ctx.GuildId(), panel, whoClaimed) - if err != nil { - ctx.HandleError(err) - return - } - - if !claimerHasAccess { - filteredOverwrites := make([]channel.PermissionOverwrite, 0, len(overwrites)) - for _, ow := range overwrites { - if ow.Id != whoClaimed || ow.Type != channel.PermissionTypeMember { - filteredOverwrites = append(filteredOverwrites, ow) - } - } - overwrites = filteredOverwrites - - switch claimSettings.SwitchPanelClaimBehavior { - case database.SwitchPanelKeepAccess: - // Preserve the claimer's existing overwrite, falling back to the full set - if existing, ok := logic.FindMemberOverwrite(ch.PermissionOverwrites, whoClaimed); ok { - overwrites = append(overwrites, existing) - } else { - overwrites = append(overwrites, channel.PermissionOverwrite{ - Id: whoClaimed, - Type: channel.PermissionTypeMember, - Allow: discordpermission.BuildPermissions(logic.StandardPermissions[:]...), - Deny: 0, - }) - } - case database.SwitchPanelRemoveOnUnclaim: - overwrites = append(overwrites, channel.PermissionOverwrite{ - Id: whoClaimed, - Type: channel.PermissionTypeMember, - Allow: 0, - Deny: discordpermission.BuildPermissions(discordpermission.ViewChannel), - }) - } - } - } - - // Generate new channel name - newChannelName, err := logic.GenerateChannelName(ctx.Context, ctx.Worker(), panel, ticket.GuildId, ticket.Id, ticket.UserId, nil) - if err != nil { - ctx.HandleError(err) - return - } - - // Always update the name to match the new panel's naming scheme - shouldUpdateName := true - claimedChannelName, _ := logic.GenerateChannelName(ctx.Context, ctx.Worker(), panel, ticket.GuildId, ticket.Id, ticket.UserId, &whoClaimed) - if ch.Name != claimedChannelName { - shouldUpdateName = false - } - - // Update channel - data := rest.ModifyChannelData{ - PermissionOverwrites: overwrites, - } - if shouldUpdateName { - data.Name = newChannelName - } - - member, err := ctx.Member() - auditReason := fmt.Sprintf("Unclaimed ticket %d", ticket.Id) - if err == nil { - auditReason = fmt.Sprintf("Unclaimed ticket %d by %s", ticket.Id, member.User.Username) - } - - reasonCtx := request.WithAuditReason(ctx, auditReason) - if _, err := ctx.Worker().ModifyChannel(reasonCtx, ticketChannelId, data); err != nil { + // Unclaim the ticket + if err := logic.UnclaimTicket(ctx.Context, ctx, ticket, whoClaimed); err != nil { ctx.HandleError(err) return } diff --git a/bot/command/manager/manager.go b/bot/command/manager/manager.go index f5f4e02c..49eee235 100644 --- a/bot/command/manager/manager.go +++ b/bot/command/manager/manager.go @@ -5,8 +5,8 @@ import ( "github.com/TicketsBot-cloud/gdl/rest" "github.com/TicketsBot-cloud/worker/bot/command/impl/admin" "github.com/TicketsBot-cloud/worker/bot/command/impl/general" + "github.com/TicketsBot-cloud/worker/bot/command/impl/kb" "github.com/TicketsBot-cloud/worker/bot/command/impl/settings" - "github.com/TicketsBot-cloud/worker/bot/command/impl/settings/setup" "github.com/TicketsBot-cloud/worker/bot/command/impl/statistics" "github.com/TicketsBot-cloud/worker/bot/command/impl/tags" "github.com/TicketsBot-cloud/worker/bot/command/impl/tickets" @@ -45,11 +45,13 @@ func (cm *CommandManager) RegisterCommands() { cm.registry["removeadmin"] = settings.RemoveAdminCommand{} cm.registry["removesupport"] = settings.RemoveSupportCommand{} cm.registry["premium"] = settings.PremiumCommand{} - cm.registry["setup"] = setup.SetupCommand{} + cm.registry["setup"] = settings.SetupCommand{} cm.registry["viewstaff"] = settings.ViewStaffCommand{} cm.registry["stats"] = statistics.StatsCommand{} + cm.registry["kb"] = kb.KBCommand{} + cm.registry["managetags"] = tags.ManageTagsCommand{} cm.registry["tag"] = tags.TagCommand{} diff --git a/bot/command/messageresponse.go b/bot/command/messageresponse.go index ed8c3fec..0010ecd0 100644 --- a/bot/command/messageresponse.go +++ b/bot/command/messageresponse.go @@ -116,12 +116,17 @@ func (r *MessageResponse) IntoWebhookBody() rest.WebhookBody { func (r *MessageResponse) IntoWebhookEditBody() rest.WebhookEditBody { data := rest.WebhookEditBody{ - Content: r.Content, - Embeds: r.Embeds, AllowedMentions: r.AllowedMentions, Components: r.Components, } + // Components V2 messages cannot carry content or embeds. The V2 flag is sticky + // on the message once sent, so it does not need re-applying when editing. + if !r.isComponentsV2() { + data.Content = r.Content + data.Embeds = r.Embeds + } + // Discord API doesn't remove if null if data.Components == nil { data.Components = make([]component.Component, 0) @@ -131,11 +136,15 @@ func (r *MessageResponse) IntoWebhookEditBody() rest.WebhookEditBody { } func (r *MessageResponse) IntoUpdateMessageResponse() (res interaction.ResponseUpdateMessageData) { - if r.Content != "" { - res.Content = &r.Content + // Components V2 messages cannot carry content or embeds. The V2 flag is sticky + // on the message once sent, so it does not need re-applying when editing. + if !r.isComponentsV2() { + if r.Content != "" { + res.Content = &r.Content + } + res.Embeds = r.Embeds } - res.Embeds = r.Embeds res.Components = r.Components // Discord API doesn't remove if null @@ -146,6 +155,10 @@ func (r *MessageResponse) IntoUpdateMessageResponse() (res interaction.ResponseU return } +func (r *MessageResponse) isComponentsV2() bool { + return r.Flags&uint(message.FlagComponentsV2) != 0 +} + func MessageIntoMessageResponse(msg message.Message) MessageResponse { // TODO: Fix types embeds := make([]*embed.Embed, len(msg.Embeds)) diff --git a/bot/command/messageresponse_test.go b/bot/command/messageresponse_test.go new file mode 100644 index 00000000..9079e282 --- /dev/null +++ b/bot/command/messageresponse_test.go @@ -0,0 +1,73 @@ +package command + +import ( + "testing" + + "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 TestMessageResponseEditsOmitContentAndEmbedsUnderComponentsV2(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 + wantContentEmbeds bool + }{ + { + name: "components v2 omits content and embeds", + flags: message.SumFlags(message.FlagComponentsV2), + wantContentEmbeds: false, + }, + { + name: "ephemeral components v2 omits content and embeds", + flags: message.SumFlags(message.FlagEphemeral, message.FlagComponentsV2), + wantContentEmbeds: false, + }, + { + name: "legacy response keeps content and embeds", + flags: 0, + wantContentEmbeds: true, + }, + { + name: "ephemeral legacy response keeps content and embeds", + flags: message.SumFlags(message.FlagEphemeral), + wantContentEmbeds: 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, + } + + update := res.IntoUpdateMessageResponse() + webhook := res.IntoWebhookEditBody() + + // Components must always survive an edit. + require.Len(t, update.Components, 1) + require.Len(t, webhook.Components, 1) + + if tt.wantContentEmbeds { + require.NotNil(t, update.Content) + require.Equal(t, "some text", *update.Content) + require.Len(t, update.Embeds, 1) + require.Equal(t, "some text", webhook.Content) + require.Len(t, webhook.Embeds, 1) + } else { + require.Nil(t, update.Content) + require.Nil(t, update.Embeds) + require.Empty(t, webhook.Content) + require.Nil(t, webhook.Embeds) + } + }) + } +} diff --git a/bot/dbclient/analytics.go b/bot/dbclient/analytics.go deleted file mode 100644 index eafdb733..00000000 --- a/bot/dbclient/analytics.go +++ /dev/null @@ -1,38 +0,0 @@ -package dbclient - -import ( - "context" - "time" - - "github.com/TicketsBot-cloud/analytics-client" - "github.com/TicketsBot-cloud/worker/config" - "go.uber.org/zap" -) - -var Analytics *analytics.Client - -func ConnectAnalytics(logger *zap.Logger) { - logger.Info("Connecting to Clickhouse", - zap.String("address", config.Conf.Clickhouse.Address), - zap.String("database", config.Conf.Clickhouse.Database), - zap.String("username", config.Conf.Clickhouse.Username), - zap.Int("threads", config.Conf.Clickhouse.Threads), - ) - - Analytics = analytics.Connect( - config.Conf.Clickhouse.Address, - config.Conf.Clickhouse.Threads, - config.Conf.Clickhouse.Database, - config.Conf.Clickhouse.Username, - config.Conf.Clickhouse.Password, - time.Second*10, - ) - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - defer cancel() - - if err := Analytics.Ping(ctx); err != nil { - logger.Error("Clickhouse didn't response to ping", zap.Error(err)) - return - } -} diff --git a/bot/exposures/retention.go b/bot/exposures/retention.go new file mode 100644 index 00000000..3975b307 --- /dev/null +++ b/bot/exposures/retention.go @@ -0,0 +1,80 @@ +// Package exposures enforces retention on the experiment exposure table. +// +// It lives in the worker rather than cleanupdaemon because the worker already +// has active local replaces for database and common. cleanupdaemon pins both, so +// adding this there would break a module that currently builds. +package exposures + +import ( + "context" + "time" + + "github.com/TicketsBot-cloud/common/sentry" + "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/bot/redis" + "github.com/TicketsBot-cloud/worker/config" + "go.uber.org/zap" +) + +const ( + // checkInterval is how often a pod considers running the purge. The Redis + // guard below is what makes the purge itself daily. + checkInterval = time.Hour + + // lockKey guards the purge so that one pod runs it, not all twelve. The + // DELETE is idempotent, so this is about avoiding pointless duplicate work + // rather than correctness. StartProductMetricsLoop has every gateway pod + // refresh the same materialised views concurrently; don't repeat that. + lockKey = "featureflags:exposure_retention_lock" + lockTTL = 23 * time.Hour +) + +func StartRetentionLoop(logger *zap.Logger) { + retention := config.Conf.ExperimentExposureRetention + if retention <= 0 { + logger.Info("Experiment exposure retention disabled") + return + } + + ticker := time.NewTicker(checkInterval) + defer ticker.Stop() + + purge(logger, retention) + + for range ticker.C { + purge(logger, retention) + } +} + +func purge(logger *zap.Logger, retention time.Duration) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + won, err := redis.Client.SetNX(ctx, lockKey, 1, lockTTL).Result() + if err != nil { + logger.Warn("Failed to take exposure retention lock", zap.Error(err)) + return + } + + if !won { + return + } + + deleted, err := dbclient.Client.ExperimentExposures.DeleteOlderThan(ctx, retention) + if err != nil { + sentry.Error(err) + logger.Error("Failed to purge old experiment exposures", zap.Error(err)) + + // Release the lock so another pod, or this one next hour, retries rather + // than waiting out the full TTL after a transient failure. + if err := redis.Client.Del(ctx, lockKey).Err(); err != nil { + logger.Warn("Failed to release exposure retention lock", zap.Error(err)) + } + + return + } + + logger.Info("Purged old experiment exposures", + zap.Int64("deleted", deleted), + zap.Duration("retention", retention)) +} diff --git a/bot/integrationowners/cache.go b/bot/integrationowners/cache.go new file mode 100644 index 00000000..397ec6f9 --- /dev/null +++ b/bot/integrationowners/cache.go @@ -0,0 +1,68 @@ +package integrationowners + +import ( + "context" + "time" + + "github.com/TicketsBot-cloud/gdl/objects/user" + "github.com/TicketsBot-cloud/gdl/rest" + "github.com/TicketsBot-cloud/worker/bot/cache" + "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/config" + "go.uber.org/zap" +) + +// refreshInterval is how often public-integration owners are re-fetched from Discord. +const refreshInterval = 30 * 24 * time.Hour + +// RefreshCache fetches the Discord profile of every public-integration owner and +// stores it in the user cache, so integration authors always resolve (and stay +// reasonably fresh) even when the owner shares no guild with the bot. +func RefreshCache(ctx context.Context, logger *zap.Logger) error { + ownerIds, err := dbclient.Client.CustomIntegrations.ListPublicOwnerIds(ctx) + if err != nil { + return err + } + + users := make([]user.User, 0, len(ownerIds)) + for _, ownerId := range ownerIds { + // RateLimiter is nil: the worker routes REST through the Discord proxy. + u, err := rest.GetUser(ctx, config.Conf.Discord.Token, nil, ownerId) + if err != nil { + // Deleted accounts / transient errors: skip so one owner can't abort the batch. + logger.Warn("Failed to fetch integration owner", zap.Uint64("owner_id", ownerId), zap.Error(err)) + continue + } + + users = append(users, u) + } + + if len(users) == 0 { + return nil + } + + return cache.Client.StoreUsers(ctx, users) +} + +func StartCacheRefreshLoop(logger *zap.Logger) { + logger.Info("Starting public integration owner cache refresh loop") + + if err := RefreshCache(context.Background(), logger); err != nil { + logger.Error("Failed to refresh public integration owner cache on startup", zap.Error(err)) + } else { + logger.Info("Refreshed public integration owner cache") + } + + timer := time.NewTicker(refreshInterval) + + for { + <-timer.C + + if err := RefreshCache(context.Background(), logger); err != nil { + logger.Error("Failed to refresh public integration owner cache", zap.Error(err)) + continue + } + + logger.Info("Refreshed public integration owner cache") + } +} diff --git a/bot/integrations/customintegrations.go b/bot/integrations/customintegrations.go index 2198c160..6f791b06 100644 --- a/bot/integrations/customintegrations.go +++ b/bot/integrations/customintegrations.go @@ -17,7 +17,7 @@ import ( ) var ( - blacklistedHeaders = []string{"user-agent", "x-real-ip", "cache-control", "content-type", "content-length", "expect", "max-forwards", "pragma", "range", "te", "if-match", "if-none-match", "if-modified-since", "if-unmodified-since", "if-range", "accept", "from", "referer"} + blacklistedHeaders = []string{"user-agent", "x-real-ip", "cache-control", "content-type", "content-length", "expect", "max-forwards", "pragma", "range", "te", "if-match", "if-none-match", "if-modified-since", "if-unmodified-since", "if-range", "accept", "from", "referer", "host", "authorization", "cookie", "set-cookie", "connection", "transfer-encoding", "upgrade", "proxy-authorization"} blacklistedHeaderPrefixes = []string{ "x-forwarded-", "x-proxy-", @@ -58,7 +58,7 @@ func Fetch( // Apply headers headerMap := make(map[string]string) for _, header := range headers { - if isHeaderBlacklisted(header.Name) { + if IsHeaderBlacklisted(header.Name) { continue } @@ -139,7 +139,7 @@ outer: return parsed } -func isHeaderBlacklisted(name string) bool { +func IsHeaderBlacklisted(name string) bool { name = strings.ToLower(name) name = strings.ReplaceAll(name, " ", "") diff --git a/bot/integrations/placeholders.go b/bot/integrations/placeholders.go new file mode 100644 index 00000000..feb63cb3 --- /dev/null +++ b/bot/integrations/placeholders.go @@ -0,0 +1,60 @@ +package integrations + +import ( + "encoding/json" + "net/url" + "strings" +) + +type SubstitutionScope int + +const ( + ScopeUrl SubstitutionScope = iota + ScopeHeader + ScopeBody +) + +func Substitute(template string, scope SubstitutionScope, values map[string]func() string) string { + if template == "" { + return template + } + + for name, resolve := range values { + token := "%" + name + "%" + if !strings.Contains(template, token) { + continue + } + + template = strings.ReplaceAll(template, token, EscapeForScope(resolve(), scope)) + } + + return template +} + +func EscapeForScope(value string, scope SubstitutionScope) string { + switch scope { + case ScopeUrl: + return url.QueryEscape(value) + case ScopeHeader: + return stripControlCharacters(value) + case ScopeBody: + encoded, err := json.Marshal(value) + if err != nil { + return "" + } + + return string(encoded[1 : len(encoded)-1]) + default: + return value + } +} + +func stripControlCharacters(value string) string { + return strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + + return r + }, value) +} diff --git a/bot/listeners/channeldelete.go b/bot/listeners/channeldelete.go index c152dd3d..a318f5ba 100644 --- a/bot/listeners/channeldelete.go +++ b/bot/listeners/channeldelete.go @@ -28,10 +28,4 @@ func OnChannelDelete(worker *worker.Context, e events.ChannelDelete) { sentry.Error(err) } - // if this is an archive channel, delete it - if err := sentry.WithSpan1(ctx, "Delete archive channel by channel", func(span *sentry.Span) error { - return dbclient.Client.ArchiveChannel.DeleteByChannel(ctx, e.Id) - }); err != nil { - sentry.Error(err) - } } diff --git a/bot/listeners/listeners.go b/bot/listeners/listeners.go index 40e40a4f..e434d479 100644 --- a/bot/listeners/listeners.go +++ b/bot/listeners/listeners.go @@ -6,7 +6,6 @@ package listeners import ( "encoding/json" "fmt" - "github.com/TicketsBot-cloud/gdl/gateway/payloads" "github.com/TicketsBot-cloud/gdl/gateway/payloads/events" "github.com/TicketsBot-cloud/worker" diff --git a/bot/listeners/memberleave.go b/bot/listeners/memberleave.go index 832c8925..1df166d5 100644 --- a/bot/listeners/memberleave.go +++ b/bot/listeners/memberleave.go @@ -28,49 +28,52 @@ func OnMemberLeave(worker *worker.Context, e events.GuildMemberRemove) { sentry.Error(err) } - // auto close - settings, err := dbclient.Client.AutoClose.Get(ctx, e.GuildId) + // auto close on user leave - check per-panel auto-close settings + tickets, err := dbclient.Client.Tickets.GetOpenByUser(ctx, e.GuildId, e.User.Id) if err != nil { sentry.Error(err) } else { - // check setting is enabled - if settings.Enabled && settings.OnUserLeave != nil && *settings.OnUserLeave { - // get open tickets by user - tickets, err := dbclient.Client.Tickets.GetOpenByUser(ctx, e.GuildId, e.User.Id) + for _, ticket := range tickets { + if ticket.PanelId == nil { + continue + } + + autoCloseSettings, err := dbclient.Client.PanelAutoClose.Get(ctx, *ticket.PanelId) if err != nil { sentry.Error(err) - } else { - for _, ticket := range tickets { - isExcluded, err := dbclient.Client.AutoCloseExclude.IsExcluded(ctx, e.GuildId, ticket.Id) - if err != nil { - sentry.Error(err) - continue - } - - if isExcluded { - continue - } + continue + } - // verify ticket exists + prevent potential panic - if ticket.ChannelId == nil { - return - } + if !autoCloseSettings.Enabled || autoCloseSettings.OnUserLeave == nil || !*autoCloseSettings.OnUserLeave { + continue + } - // get premium status - premiumTier, err := utils.PremiumClient.GetTierByGuildId(ctx, ticket.GuildId, true, worker.Token, worker.RateLimiter) - if err != nil { - sentry.Error(err) - return - } + isExcluded, err := dbclient.Client.AutoCloseExclude.IsExcluded(ctx, e.GuildId, ticket.Id) + if err != nil { + sentry.Error(err) + continue + } - ctx, cancel := context.WithTimeout(context.Background(), constants.TimeoutCloseTicket) + if isExcluded { + continue + } - cc := cmdcontext.NewAutoCloseContext(ctx, worker, e.GuildId, *ticket.ChannelId, worker.BotId, premiumTier) - logic.CloseTicket(ctx, cc, gdlUtils.StrPtr("Automatically closed due to user leaving the server"), true) + if ticket.ChannelId == nil { + continue + } - cancel() - } + premiumTier, err := utils.PremiumClient.GetTierByGuildId(ctx, ticket.GuildId, true, worker.Token, worker.RateLimiter) + if err != nil { + sentry.Error(err) + continue } + + ctx, cancel := context.WithTimeout(context.Background(), constants.TimeoutCloseTicket) + + cc := cmdcontext.NewAutoCloseContext(ctx, worker, e.GuildId, *ticket.ChannelId, worker.BotId, premiumTier) + logic.CloseTicket(ctx, cc, gdlUtils.StrPtr("Automatically closed due to user leaving the server"), true) + + cancel() } } } diff --git a/bot/listeners/messagequeue/categoryupdate.go b/bot/listeners/messagequeue/categoryupdate.go new file mode 100644 index 00000000..e50146d5 --- /dev/null +++ b/bot/listeners/messagequeue/categoryupdate.go @@ -0,0 +1,81 @@ +package messagequeue + +import ( + "context" + "time" + + ticketmodel "github.com/TicketsBot-cloud/common/model" + "github.com/TicketsBot-cloud/common/rpc" + rpcmodel "github.com/TicketsBot-cloud/common/rpc/model" + "github.com/TicketsBot-cloud/worker/bot/dbclient" + "go.uber.org/zap" +) + +const ( + categoryUpdateTopic = "tickets.rpc.categoryupdate" + categoryUpdateDelay = 30 * time.Second + categoryUpdateInterval = 10 * time.Second +) + +func StartCategoryUpdatePublisher(client *rpc.Client, logger *zap.Logger) { + ticker := time.NewTicker(categoryUpdateInterval) + defer ticker.Stop() + + publishReadyCategoryUpdates(client, logger) + for range ticker.C { + publishReadyCategoryUpdates(client, logger) + } +} + +func publishReadyCategoryUpdates(client *rpc.Client, logger *zap.Logger) { + ctx, cancel := context.WithTimeout(context.Background(), categoryUpdateInterval) + defer cancel() + + items, err := dbclient.Client.CategoryUpdateQueue.GetReadyForUpdate(ctx, categoryUpdateDelay) + if err != nil { + logger.Error("Failed to load category update queue", zap.Error(err)) + return + } + + for _, item := range items { + if item.ChannelId == nil || item.PanelId == nil { + continue + } + + panel, err := dbclient.Client.Panel.GetById(ctx, *item.PanelId) + if err != nil { + logger.Error("Failed to load panel for category update", zap.Error(err), zap.Int("panel_id", *item.PanelId)) + continue + } + + categoryId, ok := categoryForStatus(item.NewStatus, panel.TargetCategory, panel.PendingCategory) + if !ok { + continue + } + + if err := client.ProduceSyncJson(ctx, categoryUpdateTopic, rpcmodel.TicketStatusUpdate{ + Ticket: rpcmodel.Ticket{ + GuildId: item.GuildId, + Id: item.TicketId, + }, + ChannelId: *item.ChannelId, + NewCategoryId: categoryId, + }); err != nil { + logger.Error("Failed to publish category update", zap.Error(err), zap.Uint64("guild_id", item.GuildId), zap.Int("ticket_id", item.TicketId)) + } + } +} + +func categoryForStatus(status ticketmodel.TicketStatus, openCategory uint64, pendingCategory *uint64) (uint64, bool) { + switch status { + case ticketmodel.TicketStatusOpen: + return openCategory, openCategory != 0 + case ticketmodel.TicketStatusPending: + if pendingCategory == nil { + return 0, false + } + return *pendingCategory, *pendingCategory != 0 + default: + return 0, false + } +} diff --git a/bot/listeners/messagequeue/closereasonupdate.go b/bot/listeners/messagequeue/closereasonupdate.go index fa8edd64..4ff5d4cc 100644 --- a/bot/listeners/messagequeue/closereasonupdate.go +++ b/bot/listeners/messagequeue/closereasonupdate.go @@ -62,10 +62,17 @@ func ListenCloseReasonUpdate() { closedBy = *closeMetadata.ClosedBy } - settings, err := dbclient.Client.Settings.Get(ctx, payload.GuildId) - if err != nil { - sentry.Error(err) - return + var storeTranscripts, feedbackEnabled bool + if ticket.PanelId != nil { + p, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) + if err != nil { + sentry.Error(err) + return + } + if p.PanelId != 0 { + storeTranscripts = p.StoreTranscripts + feedbackEnabled = p.FeedbackEnabled + } } var rating *uint8 @@ -79,11 +86,11 @@ func ListenCloseReasonUpdate() { return } - if err := logic.EditGuildArchiveMessageIfExists(ctx, workerCtx, ticket, settings, hasFeedback, closedBy, closeMetadata.Reason, rating); err != nil { + if err := logic.EditGuildArchiveMessageIfExists(ctx, workerCtx, ticket, storeTranscripts, hasFeedback, closedBy, closeMetadata.Reason, rating); err != nil { sentry.Error(err) } - if err := logic.EditDMMessageIfExists(ctx, workerCtx, ticket, settings, closedBy, closeMetadata.Reason, rating); err != nil { + if err := logic.EditDMMessageIfExists(ctx, workerCtx, ticket, storeTranscripts, feedbackEnabled, closedBy, closeMetadata.Reason, rating); err != nil { sentry.Error(err) } }() diff --git a/bot/listeners/messagequeue/ticketclaim.go b/bot/listeners/messagequeue/ticketclaim.go new file mode 100644 index 00000000..fec06a05 --- /dev/null +++ b/bot/listeners/messagequeue/ticketclaim.go @@ -0,0 +1,150 @@ +package messagequeue + +import ( + "context" + "fmt" + + "github.com/TicketsBot-cloud/common/claimrelay" + "github.com/TicketsBot-cloud/common/sentry" + "github.com/TicketsBot-cloud/gdl/objects/channel/embed" + "github.com/TicketsBot-cloud/worker" + "github.com/TicketsBot-cloud/worker/bot/cache" + "github.com/TicketsBot-cloud/worker/bot/command" + cmdcontext "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/dbclient" + "github.com/TicketsBot-cloud/worker/bot/errorcontext" + "github.com/TicketsBot-cloud/worker/bot/logic" + "github.com/TicketsBot-cloud/worker/bot/redis" + "github.com/TicketsBot-cloud/worker/bot/utils" + "github.com/TicketsBot-cloud/worker/config" + "github.com/TicketsBot-cloud/worker/i18n" +) + +func ListenTicketClaim() { + ch := make(chan claimrelay.TicketClaim) + go claimrelay.Listen(redis.Client, ch) + + for payload := range ch { + payload := payload + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), constants.TimeoutOpenTicket) + defer cancel() + + // Get the ticket struct + ticket, err := dbclient.Client.Tickets.Get(ctx, payload.TicketId, payload.GuildId) + if err != nil { + sentry.Error(err) + return + } + + // Check that this is a valid ticket + if ticket.GuildId == 0 { + return + } + + errorContext := errorcontext.WorkerErrorContext{ + Guild: ticket.GuildId, + User: payload.UserId, + } + + // Claim/unclaim require a channel and are not supported on thread-mode tickets + if ticket.ChannelId == nil || ticket.IsThread { + return + } + + // Get bot token for guild + var token string + var botId uint64 + { + whiteLabelBotId, isWhitelabel, err := dbclient.Client.WhitelabelGuilds.GetBotByGuild(ctx, payload.GuildId) + if err != nil { + sentry.ErrorWithContext(err, errorContext) + } + + if isWhitelabel { + bot, err := dbclient.Client.Whitelabel.GetByBotId(ctx, whiteLabelBotId) + if err != nil { + sentry.ErrorWithContext(err, errorContext) + return + } + + if bot.Token == "" { + token = config.Conf.Discord.Token + } else { + token = bot.Token + botId = whiteLabelBotId + } + } else { + token = config.Conf.Discord.Token + } + } + + // Create worker context + workerCtx := &worker.Context{ + Token: token, + BotId: botId, + IsWhitelabel: botId != 0, + Cache: cache.Client, // TODO: Less hacky + RateLimiter: nil, // Use http-proxy ratelimit functionality + } + + // Fall back to the configured id for the main (non-whitelabel) bot + if workerCtx.BotId == 0 { + workerCtx.BotId = config.Conf.Discord.PublicBotId + } + + premiumTier, err := utils.PremiumClient.GetTierByGuildId(ctx, payload.GuildId, true, token, workerCtx.RateLimiter) + if err != nil { + sentry.ErrorWithContext(err, errorContext) + return + } + + cc := cmdcontext.NewDashboardContext(ctx, workerCtx, ticket.GuildId, *ticket.ChannelId, payload.UserId, premiumTier) + + if payload.Claim { + // The claim is already recorded by the API; apply the channel changes + if err := logic.ApplyClaim(ctx, &cc, ticket, payload.UserId); err != nil { + sentry.ErrorWithContext(err, errorContext) + return + } + } else { + whoClaimed, err := dbclient.Client.TicketClaims.Get(ctx, ticket.GuildId, ticket.Id) + if err != nil { + sentry.ErrorWithContext(err, errorContext) + return + } + + // Already unclaimed - nothing to do + if whoClaimed == 0 { + return + } + + if err := logic.UnclaimTicket(ctx, &cc, ticket, whoClaimed); err != nil { + sentry.ErrorWithContext(err, errorContext) + return + } + } + + // Update the welcome message claim button + if err := logic.UpdateWelcomeMessageClaimButton(ctx, workerCtx, &cc, ticket, payload.Claim); err != nil { + sentry.ErrorWithContext(err, errorContext) + } + + // Post the claimed/unclaimed notice to the ticket channel (DashboardContext would DM) + var noticeEmbed *embed.Embed + if payload.Claim { + noticeEmbed = utils.BuildEmbed(&cc, customisation.Green, i18n.TitleClaimed, i18n.MessageClaimed, nil, fmt.Sprintf("<@%d>", payload.UserId)) + } else { + noticeEmbed = utils.BuildEmbed(&cc, customisation.Green, i18n.TitleUnclaimed, i18n.MessageUnclaimed, nil) + } + + notice := command.NewEmbedMessageResponse(noticeEmbed) + if _, err := workerCtx.CreateMessageComplex(*ticket.ChannelId, notice.IntoCreateMessageData()); err != nil { + sentry.ErrorWithContext(err, errorContext) + } + }() + } +} diff --git a/bot/listeners/threadmembersupdate.go b/bot/listeners/threadmembersupdate.go index d424fe08..d10ae7b0 100644 --- a/bot/listeners/threadmembersupdate.go +++ b/bot/listeners/threadmembersupdate.go @@ -18,12 +18,6 @@ func OnThreadMembersUpdate(worker *worker.Context, e events.ThreadMembersUpdate) ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) // TODO: Propagate context defer cancel() - settings, err := dbclient.Client.Settings.Get(ctx, e.GuildId) - if err != nil { - sentry.ErrorWithContext(err, errorcontext.WorkerErrorContext{Guild: e.GuildId}) - return - } - ticket, err := dbclient.Client.Tickets.GetByChannelAndGuild(ctx, e.ThreadId, e.GuildId) if err != nil { sentry.ErrorWithContext(err, errorcontext.WorkerErrorContext{Guild: e.GuildId}) @@ -61,10 +55,8 @@ func OnThreadMembersUpdate(worker *worker.Context, e events.ThreadMembersUpdate) } var notificationChannel *uint64 - if panel != nil && panel.TicketNotificationChannel != nil { + if panel != nil { notificationChannel = panel.TicketNotificationChannel - } else if settings.TicketNotificationChannel != nil { - notificationChannel = settings.TicketNotificationChannel } if notificationChannel != nil { diff --git a/bot/listeners/threadupdate.go b/bot/listeners/threadupdate.go index 872a79c7..021c9620 100644 --- a/bot/listeners/threadupdate.go +++ b/bot/listeners/threadupdate.go @@ -24,12 +24,6 @@ func OnThreadUpdate(worker *worker.Context, e events.ThreadUpdate) { return } - settings, err := dbclient.Client.Settings.Get(ctx, e.GuildId) - if err != nil { - sentry.ErrorWithContext(err, errorcontext.WorkerErrorContext{Guild: e.GuildId}) - return - } - ticket, err := dbclient.Client.Tickets.GetByChannelAndGuild(ctx, e.Id, e.GuildId) if err != nil { sentry.ErrorWithContext(err, errorcontext.WorkerErrorContext{Guild: e.GuildId}) @@ -72,7 +66,7 @@ func OnThreadUpdate(worker *worker.Context, e events.ThreadUpdate) { return } - if settings.TicketNotificationChannel != nil { + if panel != nil && panel.TicketNotificationChannel != nil { staffCount, err := logic.GetStaffInThread(ctx, worker, ticket, e.Id) if err != nil { sentry.ErrorWithContext(err, errorcontext.WorkerErrorContext{Guild: e.GuildId}) @@ -81,7 +75,7 @@ func OnThreadUpdate(worker *worker.Context, e events.ThreadUpdate) { name, _ := logic.GenerateChannelName(ctx, worker, panel, ticket.GuildId, ticket.Id, ticket.UserId, nil) data := logic.BuildThreadReopenMessage(ctx, worker, ticket.GuildId, ticket.UserId, name, ticket.Id, panel, staffCount, premiumTier) - msg, err := worker.CreateMessageComplex(*settings.TicketNotificationChannel, data.IntoCreateMessageData()) + msg, err := worker.CreateMessageComplex(*panel.TicketNotificationChannel, data.IntoCreateMessageData()) if err != nil { sentry.ErrorWithContext(err, errorcontext.WorkerErrorContext{Guild: e.GuildId}) return diff --git a/bot/logic/claim.go b/bot/logic/claim.go index 3f6a38aa..a687f030 100644 --- a/bot/logic/claim.go +++ b/bot/logic/claim.go @@ -22,6 +22,16 @@ import ( "golang.org/x/sync/errgroup" ) +// AlreadyClaimedError is returned when the ticket is already claimed. ClaimerId is the +// current claimer. +type AlreadyClaimedError struct { + ClaimerId uint64 +} + +func (e AlreadyClaimedError) Error() string { + return fmt.Sprintf("ticket already claimed by %d", e.ClaimerId) +} + // ClaimTicket TODO: Keep /add members func ClaimTicket(ctx context.Context, cmd registry.CommandContext, ticket database.Ticket, userId uint64) error { if ticket.ChannelId == nil { @@ -34,6 +44,26 @@ func ClaimTicket(ctx context.Context, cmd registry.CommandContext, ticket databa return nil } + // Claim only if the ticket is currently unclaimed, otherwise block it + claimed, claimer, err := dbclient.Client.TicketClaims.TryClaim(ctx, ticket.GuildId, ticket.Id, userId) + if err != nil { + return err + } + + if !claimed { + return AlreadyClaimedError{ClaimerId: claimer} + } + + return ApplyClaim(ctx, cmd, ticket, userId) +} + +// ApplyClaim applies the channel changes (permission overwrites and rename) for a ticket +// whose claim is already recorded in the database. +func ApplyClaim(ctx context.Context, cmd registry.CommandContext, ticket database.Ticket, userId uint64) error { + if ticket.ChannelId == nil { + return errors.New("channel ID is nil") + } + // Get panel var panel *database.Panel if ticket.PanelId != nil { @@ -47,11 +77,6 @@ func ClaimTicket(ctx context.Context, cmd registry.CommandContext, ticket databa } } - // Set to claimed in DB - if err := dbclient.Client.TicketClaims.Set(ctx, ticket.GuildId, ticket.Id, userId); err != nil { - return err - } - newOverwrites, err := GenerateClaimedOverwrites(ctx, cmd.Worker(), ticket, userId) if err != nil { return err @@ -109,16 +134,150 @@ func ClaimTicket(ctx context.Context, cmd registry.CommandContext, ticket databa return nil } +// UnclaimTicket removes the claim: deletes the record, restores the unclaimed channel +// permissions, and renames the channel. The caller handles the welcome button and reply. +func UnclaimTicket(ctx context.Context, cmd registry.CommandContext, ticket database.Ticket, whoClaimed uint64) error { + if ticket.ChannelId == nil { + return errors.New("channel ID is nil") + } + + // Set to unclaimed in DB + if err := dbclient.Client.TicketClaims.Delete(ctx, ticket.GuildId, ticket.Id); err != nil { + return err + } + + // Get panel + var panel *database.Panel + if ticket.PanelId != nil { + derefPanel, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) + if err != nil { + return err + } + + if derefPanel.PanelId != 0 { + panel = &derefPanel + } + } + + // Use the actual ticket channel ID, not the current channel (which might be a notes thread) + ticketChannelId := *ticket.ChannelId + + // Get the channel to determine its parent category + ch, err := cmd.Worker().GetChannel(ticketChannelId) + if err != nil { + return err + } + + overwrites, err := CreateOverwrites(ctx, cmd, ticket.UserId, panel, ch.ParentId.Value) + if err != nil { + return err + } + + // Handle claimer access based on SwitchPanelClaimBehavior setting + claimSettings, err := dbclient.Client.ClaimSettings.Get(ctx, ticket.GuildId) + if err != nil { + return err + } + + if claimSettings.SwitchPanelClaimBehavior == database.SwitchPanelKeepAccess || + claimSettings.SwitchPanelClaimBehavior == database.SwitchPanelRemoveOnUnclaim { + + claimerHasAccess, err := HasPermissionForPanel(ctx, cmd.Worker(), ticket.GuildId, panel, whoClaimed) + if err != nil { + return err + } + + if !claimerHasAccess { + filteredOverwrites := make([]channel.PermissionOverwrite, 0, len(overwrites)) + for _, ow := range overwrites { + if ow.Id != whoClaimed || ow.Type != channel.PermissionTypeMember { + filteredOverwrites = append(filteredOverwrites, ow) + } + } + overwrites = filteredOverwrites + + switch claimSettings.SwitchPanelClaimBehavior { + case database.SwitchPanelKeepAccess: + // Preserve the claimer's existing overwrite, falling back to the full set + if existing, ok := FindMemberOverwrite(ch.PermissionOverwrites, whoClaimed); ok { + overwrites = append(overwrites, existing) + } else { + overwrites = append(overwrites, channel.PermissionOverwrite{ + Id: whoClaimed, + Type: channel.PermissionTypeMember, + Allow: permission.BuildPermissions(StandardPermissions[:]...), + Deny: 0, + }) + } + case database.SwitchPanelRemoveOnUnclaim: + overwrites = append(overwrites, channel.PermissionOverwrite{ + Id: whoClaimed, + Type: channel.PermissionTypeMember, + Allow: 0, + Deny: permission.BuildPermissions(permission.ViewChannel), + }) + } + } + } + + // Generate new channel name + newChannelName, err := GenerateChannelName(ctx, cmd.Worker(), panel, ticket.GuildId, ticket.Id, ticket.UserId, nil) + if err != nil { + return err + } + + // Always update the name to match the unclaimed naming scheme, unless manually renamed + shouldUpdateName := true + claimedChannelName, _ := GenerateChannelName(ctx, cmd.Worker(), panel, ticket.GuildId, ticket.Id, ticket.UserId, &whoClaimed) + if ch.Name != claimedChannelName { + shouldUpdateName = false + } + + // Update channel + data := rest.ModifyChannelData{ + PermissionOverwrites: overwrites, + } + if shouldUpdateName { + data.Name = newChannelName + } + + auditReason := fmt.Sprintf("Unclaimed ticket %d", ticket.Id) + if member, err := cmd.Worker().GetGuildMember(ticket.GuildId, cmd.UserId()); err == nil { + auditReason = fmt.Sprintf("Unclaimed ticket %d by %s", ticket.Id, member.User.Username) + } + + reasonCtx := request.WithAuditReason(ctx, auditReason) + if _, err := cmd.Worker().ModifyChannel(reasonCtx, ticketChannelId, data); err != nil { + return err + } + + return nil +} + // GenerateClaimedOverwrites returns the full overwrite set for a claimed ticket, or // (nil, nil) if support reps can still view and type. func GenerateClaimedOverwrites(ctx context.Context, worker *worker.Context, ticket database.Ticket, claimer uint64) ([]channel.PermissionOverwrite, error) { - // Get claim settings for guild - claimSettings, err := dbclient.Client.ClaimSettings.Get(ctx, ticket.GuildId) - if err != nil { - return nil, err + // Get per-panel claim settings (SupportCanView/SupportCanType are on the panel) + supportCanView := true // defaults + supportCanType := false + + var additionalPermissions database.TicketPermissions + if ticket.PanelId != nil { + p, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) + if err != nil { + return nil, err + } + if p.PanelId != 0 { + supportCanView = p.SupportCanView + supportCanType = p.SupportCanType + additionalPermissions, err = dbclient.Client.PanelTicketPermissions.Get(ctx, p.PanelId) + if err != nil { + return nil, err + } + } } - if claimSettings.SupportCanView && claimSettings.SupportCanType { + if supportCanView && supportCanType { return nil, nil } @@ -132,11 +291,6 @@ func GenerateClaimedOverwrites(ctx context.Context, worker *worker.Context, tick return nil, err } - additionalPermissions, err := dbclient.Client.TicketPermissions.Get(ctx, ticket.GuildId) - if err != nil { - return nil, err - } - integrationRoleId, err := GetIntegrationRoleId(ctx, worker, ticket.GuildId) if err != nil { return nil, err @@ -149,12 +303,12 @@ func GenerateClaimedOverwrites(ctx context.Context, worker *worker.Context, tick } // Support can't view the ticket, and therefore can't type either - if !claimSettings.SupportCanView { + if !supportCanView { return overwritesCantView(claimerOverwrite, claimer, worker.BotId, ticket.UserId, ticket.GuildId, adminUsers, adminRoles, integrationRoleId, additionalPermissions), nil } // Support can view the ticket, but can't type - if !claimSettings.SupportCanType { + if !supportCanType { supportUsers, err := dbclient.Client.Permissions.GetSupportOnly(ctx, ticket.GuildId) if err != nil { return nil, err diff --git a/bot/logic/close.go b/bot/logic/close.go index 4c40c367..1203d00e 100644 --- a/bot/logic/close.go +++ b/bot/logic/close.go @@ -16,6 +16,7 @@ import ( "github.com/TicketsBot-cloud/gdl/objects/member" "github.com/TicketsBot-cloud/gdl/rest" "github.com/TicketsBot-cloud/gdl/rest/request" + botcache "github.com/TicketsBot-cloud/worker/bot/cache" "github.com/TicketsBot-cloud/worker/bot/command/registry" "github.com/TicketsBot-cloud/worker/bot/customisation" "github.com/TicketsBot-cloud/worker/bot/dbclient" @@ -60,10 +61,17 @@ func CloseTicket(ctx context.Context, cmd registry.CommandContext, reason *strin return } - settings, err := cmd.Settings() - if err != nil { - cmd.HandleError(err) - return + // Load panel for per-panel settings + var panel *database.Panel + if ticket.PanelId != nil { + p, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) + if err != nil { + cmd.HandleError(err) + return + } + if p.PanelId != 0 { + panel = &p + } } // Check the channel still exists - if it does not, just set to closed in the database, as this must be a request @@ -86,7 +94,8 @@ func CloseTicket(ctx context.Context, cmd registry.CommandContext, reason *strin } // Archive - if settings.StoreTranscripts { + storeTranscripts := panel != nil && panel.StoreTranscripts + if storeTranscripts { msgs := make([]message.Message, 0, 50) const limit = 100 @@ -149,6 +158,12 @@ func CloseTicket(ctx context.Context, cmd registry.CommandContext, reason *strin return } + // Count staff vs user messages for analytics + staffMessages, userMessages := countStaffAndUserMessages(ctx, cmd.GuildId(), ticket, msgs) + if err := dbclient.Client.TicketMessageCounts.Set(ctx, cmd.GuildId(), ticket.Id, staffMessages, userMessages); err != nil { + sentry.ErrorWithContext(err, errorContext) + } + if err := utils.ArchiverClient.Store(ctx, cmd.GuildId(), ticket.Id, msgs); err != nil { cmd.HandleError(err) return @@ -274,23 +289,9 @@ func CloseTicket(ctx context.Context, cmd registry.CommandContext, reason *strin // Delete join thread button if ticket.IsThread && ticket.JoinMessageId != nil { - // Determine which notification channel was used - // Priority: Panel-specific notification channel > Global notification channel var notificationChannel *uint64 - - // Get panel if this ticket has one - var panel *database.Panel - if ticket.PanelId != nil { - p, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) - if err == nil && p.PanelId != 0 { - panel = &p - } - } - - if panel != nil && panel.TicketNotificationChannel != nil { + if panel != nil { notificationChannel = panel.TicketNotificationChannel - } else if settings.TicketNotificationChannel != nil { - notificationChannel = settings.TicketNotificationChannel } if notificationChannel != nil { @@ -301,27 +302,17 @@ func CloseTicket(ctx context.Context, cmd registry.CommandContext, reason *strin } } - sendCloseEmbed(ctx, cmd, errorContext, member, settings, ticket, reason) + sendCloseEmbed(ctx, cmd, errorContext, member, panel, ticket, reason) } -func sendCloseEmbed(ctx context.Context, cmd registry.CommandContext, errorContext sentry.ErrorContext, member member.Member, settings database.Settings, ticket database.Ticket, reason *string) { - // Send logs to archive channel - var archiveChannelId *uint64 +func sendCloseEmbed(ctx context.Context, cmd registry.CommandContext, errorContext sentry.ErrorContext, member member.Member, panel *database.Panel, ticket database.Ticket, reason *string) { + storeTranscripts := panel != nil && panel.StoreTranscripts + feedbackEnabled := panel != nil && panel.FeedbackEnabled - if ticket.PanelId != nil { - acId, err := dbclient.Client.ArchiveChannel.GetByPanel(ctx, ticket.GuildId, *ticket.PanelId) - if err != nil { - sentry.ErrorWithContext(err, errorContext) - return - } - archiveChannelId = acId - } else { - acId, err := dbclient.Client.ArchiveChannel.Get(ctx, ticket.GuildId) - if err != nil { - sentry.ErrorWithContext(err, errorContext) - return - } - archiveChannelId = acId + // Send logs to archive channel (per-panel transcript channel only) + var archiveChannelId *uint64 + if panel != nil { + archiveChannelId = panel.TranscriptChannelId } var archiveChannelExists bool @@ -334,7 +325,7 @@ func sendCloseEmbed(ctx context.Context, cmd registry.CommandContext, errorConte if archiveChannelExists && archiveChannelId != nil { componentBuilders := [][]CloseEmbedElement{ { - TranscriptLinkElement(settings.StoreTranscripts), + TranscriptLinkElement(storeTranscripts), ThreadLinkElement(ticket.IsThread && ticket.ChannelId != nil), EditCloseReasonElement(), }, @@ -369,12 +360,6 @@ func sendCloseEmbed(ctx context.Context, cmd registry.CommandContext, errorConte return } - feedbackEnabled, err := dbclient.Client.FeedbackEnabled.Get(ctx, cmd.GuildId()) - if err != nil { - sentry.ErrorWithContext(err, errorContext) - return - } - // Only offer to take feedback if the user has sent a message hasSentMessage, err := dbclient.Client.Participants.HasParticipated(ctx, cmd.GuildId(), ticket.Id, ticket.UserId) if err != nil { @@ -407,7 +392,7 @@ func sendCloseEmbed(ctx context.Context, cmd registry.CommandContext, errorConte componentBuilders := [][]CloseEmbedElement{ { - TranscriptLinkElement(settings.StoreTranscripts), + TranscriptLinkElement(storeTranscripts), ThreadLinkElement(ticket.IsThread && ticket.ChannelId != nil), }, { @@ -502,3 +487,55 @@ func checkChannelExists(ctx registry.CommandContext, ticket database.Ticket) (bo return true, nil } + +func countStaffAndUserMessages(ctx context.Context, guildId uint64, ticket database.Ticket, msgs []message.Message) (staffMessages, userMessages int) { + authorIds := collections.NewSet[uint64]() + for _, msg := range msgs { + if !msg.Author.Bot { + authorIds.Add(msg.Author.Id) + } + } + + permCache := permission.NewRedisCache(redis.Client) + staffSet := collections.NewSet[uint64]() + for _, authorId := range authorIds.Collect() { + if isStaffUser(ctx, permCache, guildId, authorId) { + staffSet.Add(authorId) + } + } + + for _, msg := range msgs { + if msg.Author.Bot { + continue + } + if staffSet.Contains(msg.Author.Id) { + staffMessages++ + } else { + userMessages++ + } + } + + return +} + +func isStaffUser(ctx context.Context, permCache *permission.RedisCache, guildId, userId uint64) bool { + if cached, err := permCache.GetCachedPermissionLevel(ctx, guildId, userId); err == nil { + return cached >= permission.Support + } + + if isSupport, err := dbclient.Client.Permissions.IsSupport(ctx, guildId, userId); err == nil && isSupport { + return true + } + + if isSupport, err := dbclient.Client.SupportTeamMembers.IsSupport(ctx, guildId, userId); err == nil && isSupport { + return true + } + + if botcache.Client != nil { + if owner, err := botcache.Client.GetGuildOwner(ctx, guildId); err == nil && owner == userId { + return true + } + } + + return false +} diff --git a/bot/logic/closeembed.go b/bot/logic/closeembed.go index a43460f9..1b2df9d1 100644 --- a/bot/logic/closeembed.go +++ b/bot/logic/closeembed.go @@ -214,7 +214,8 @@ func EditDMMessageIfExists( ctx context.Context, w *worker.Context, ticket database.Ticket, - settings database.Settings, + storeTranscripts bool, + feedbackEnabled bool, closedBy uint64, reason *string, rating *uint8, @@ -228,11 +229,6 @@ func EditDMMessageIfExists( return nil } - feedbackEnabled, err := dbclient.Client.FeedbackEnabled.Get(ctx, ticket.GuildId) - if err != nil { - return err - } - hasSentMessage, err := dbclient.Client.Participants.HasParticipated(ctx, ticket.GuildId, ticket.Id, ticket.UserId) if err != nil { return err @@ -258,7 +254,7 @@ func EditDMMessageIfExists( componentBuilders := [][]CloseEmbedElement{ { - TranscriptLinkElement(settings.StoreTranscripts), + TranscriptLinkElement(storeTranscripts), ThreadLinkElement(ticket.IsThread && ticket.ChannelId != nil), }, { @@ -294,7 +290,7 @@ func EditGuildArchiveMessageIfExists( ctx context.Context, worker *worker.Context, ticket database.Ticket, - settings database.Settings, + storeTranscripts bool, viewFeedbackButton bool, closedBy uint64, reason *string, @@ -311,7 +307,7 @@ func EditGuildArchiveMessageIfExists( componentBuilders := [][]CloseEmbedElement{ { - TranscriptLinkElement(settings.StoreTranscripts), + TranscriptLinkElement(storeTranscripts), ThreadLinkElement(ticket.IsThread && ticket.ChannelId != nil), ViewFeedbackElement(viewFeedbackButton), EditCloseReasonElement(), diff --git a/bot/logic/open.go b/bot/logic/open.go index ccdb3aa5..dd9a2c38 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -9,6 +9,7 @@ import ( "time" "unicode" + "github.com/TicketsBot-cloud/common/featureflags" permcache "github.com/TicketsBot-cloud/common/permission" "github.com/TicketsBot-cloud/common/premium" "github.com/TicketsBot-cloud/common/sentry" @@ -36,7 +37,18 @@ import ( "golang.org/x/sync/errgroup" ) -func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *database.Panel, subject string, formData map[database.FormInput]string, outOfHoursTitle *string, outOfHoursWarning *string, outOfHoursColour *int) (database.Ticket, error) { +func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *database.Panel, subject string, formData map[database.FormInput]string, outOfHoursTitle *string, outOfHoursWarning *string, outOfHoursColour *int, source database.TicketSource) (database.Ticket, error) { + // Kill switch: lets us lock down ticket creation for every guild without a + // deploy if an already-published panel/multipanel/form turns out to be + // exploitable (e.g. a cross-guild channel misconfiguration). Rejecting here, + // rather than only in the dashboard, is what actually stops a live exploit, + // since existing panel buttons in Discord are unaffected by the dashboard-side + // guard on panel create/update. + if !utils.FeatureFlags.IsEnabled(ctx, "202608_FEATURE_TICKETS", featureflags.ForGuild(cmd.GuildId())) { + cmd.Reply(customisation.Red, i18n.Error, i18n.MessageOpenFeatureUnavailable) + return database.Ticket{}, nil + } + rootSpan := sentry.StartSpan(ctx, "Ticket open") rootSpan.SetTag("guild", strconv.FormatUint(cmd.GuildId(), 10)) defer rootSpan.Finish() @@ -130,49 +142,21 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat // Ensure that the panel isn't disabled span = sentry.StartSpan(rootSpan.Context(), "Check if panel is disabled") - if panel != nil && panel.ForceDisabled { - // Build premium command mention - var premiumCommand string - commands, err := command.LoadCommandIds(cmd.Worker(), cmd.Worker().BotId) - if err != nil { - sentry.Error(err) - return database.Ticket{}, err - } - - if id, ok := commands["premium"]; ok { - premiumCommand = fmt.Sprintf("", id) - } else { - premiumCommand = "`/premium`" - } - - cmd.Reply(customisation.Red, i18n.Error, i18n.MessageOpenPanelForceDisabled, premiumCommand) - return database.Ticket{}, nil - } - + panelUnavailable, err := replyIfPanelUnavailable(cmd, panel) span.Finish() - if panel != nil && panel.Disabled { - cmd.Reply(customisation.Red, i18n.Error, i18n.MessageOpenPanelDisabled) - return database.Ticket{}, nil - } - - span = sentry.StartSpan(rootSpan.Context(), "Load settings") - settings, err := cmd.Settings() if err != nil { - cmd.HandleError(err) + sentry.Error(err) return database.Ticket{}, err } - span.Finish() - // Determine if we should use threads - // If a panel is provided, use the panel's setting; otherwise use the global setting - isThread := settings.UseThreads - if panel != nil && !isThread { - isThread = panel.UseThreads + if panelUnavailable { + return database.Ticket{}, nil } - // Check if the parent channel is an announcement channel - span = sentry.StartSpan(rootSpan.Context(), "Check if parent channel is announcement channel") + // Determine if we should use threads; panel-less tickets always use channel mode + isThread := panel != nil && panel.UseThreads + if isThread { panelChannel, err := cmd.Channel() if err != nil { @@ -180,18 +164,18 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat return database.Ticket{}, err } + // Check if the parent channel is an announcement channel if panelChannel.Type != channel.ChannelTypeGuildText { cmd.Reply(customisation.Red, i18n.Error, i18n.MessageOpenThreadAnnouncementChannel) return database.Ticket{}, nil } - } - span.Finish() - // Check if the user has Send Messages in Threads - if isThread && cmd.InteractionMetadata().Member != nil { - member := cmd.InteractionMetadata().Member - if member.Permissions > 0 && !permission.HasPermissionRaw(member.Permissions, permission.SendMessagesInThreads) { - cmd.Reply(customisation.Red, i18n.Error, i18n.MessageOpenCantMessageInThreads) + // Check if the user can send messages in threads in the parent channel + if !permissionwrapper.HasPermissionsChannel( + cmd.Worker(), cmd.GuildId(), cmd.UserId(), cmd.ChannelId(), + permission.SendMessagesInThreads, + ) { + cmd.Reply(customisation.Red, i18n.Error, i18n.MessageOpenCantMessageInThreads, cmd.ChannelId()) return database.Ticket{}, nil } } @@ -199,15 +183,8 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat // If we're using a panel, then we need to create the ticket in the specified category span = sentry.StartSpan(rootSpan.Context(), "Get category") var category uint64 - if panel != nil && panel.TargetCategory != 0 { + if panel != nil { category = panel.TargetCategory - } else { // else we can just use the default category - var err error - category, err = dbclient.Client.ChannelCategory.Get(ctx, cmd.GuildId()) - if err != nil { - cmd.HandleError(err) - return database.Ticket{}, err - } } span.Finish() @@ -219,13 +196,7 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat if err != nil { useCategory = false - if restError, ok := err.(request.RestError); ok && restError.StatusCode == 404 { - if panel == nil { - if err := dbclient.Client.ChannelCategory.Delete(ctx, cmd.GuildId()); err != nil { - cmd.HandleError(err) - } - } // TODO: Else, set panel category to 0 - } + // TODO: Set panel category to 0 when it no longer exists } span.Finish() } @@ -245,7 +216,15 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat // Channel count checks if !isThread { - newCategoryId, err := checkChannelLimitAndDetermineParentId(ctx, cmd.Worker(), cmd.GuildId(), category, settings, true) + var overflowEnabled bool + var overflowCategoryId *uint64 + var panelId int + if panel != nil { + overflowEnabled = panel.OverflowEnabled + overflowCategoryId = panel.OverflowCategoryId + panelId = panel.PanelId + } + newCategoryId, err := checkChannelLimitAndDetermineParentId(ctx, cmd.Worker(), cmd.GuildId(), category, overflowEnabled, overflowCategoryId, panelId, true) if err != nil { if errors.Is(err, errGuildChannelLimitReached) { cmd.Reply(customisation.Red, i18n.Error, i18n.MessageGuildChannelLimitReached) @@ -268,7 +247,7 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat // Create channel span = sentry.StartSpan(rootSpan.Context(), "Create ticket in database") - ticketId, err := dbclient.Client.Tickets.Create(ctx, cmd.GuildId(), cmd.UserId(), isThread, panelId) + ticketId, err := dbclient.Client.Tickets.Create(ctx, cmd.GuildId(), cmd.UserId(), isThread, panelId, source) if err != nil { cmd.HandleError(err) return database.Ticket{}, err @@ -301,7 +280,8 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat if isThread { span = sentry.StartSpan(rootSpan.Context(), "Create thread") reasonCtx := request.WithAuditReason(context.Background(), auditReason) - ch, err = cmd.Worker().CreatePrivateThread(reasonCtx, cmd.ChannelId(), name, uint16(settings.ThreadArchiveDuration), false) + threadArchiveDuration := 10080 + ch, err = cmd.Worker().CreatePrivateThread(reasonCtx, cmd.ChannelId(), name, uint16(threadArchiveDuration), false) if err != nil { cmd.HandleError(err) @@ -321,13 +301,9 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat } span.Finish() - // Determine which notification channel to use - // Priority: Panel-specific notification channel > Global notification channel var notificationChannel *uint64 - if panel != nil && panel.TicketNotificationChannel != nil { + if panel != nil { notificationChannel = panel.TicketNotificationChannel - } else if settings.TicketNotificationChannel != nil { - notificationChannel = settings.TicketNotificationChannel } if notificationChannel != nil { @@ -396,7 +372,6 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat } span.Finish() - // TODO: Remove if tmp.Id == 0 { cmd.HandleError(fmt.Errorf("channel id is 0")) return database.Ticket{}, fmt.Errorf("channel id is 0") @@ -440,6 +415,11 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat // Variable to store welcome message ID for pinning later var welcomeMessageId uint64 + // The ping and the welcome message are sent from separate goroutines, so + // without a handoff whichever request finishes first lands first. Closed by + // the mention goroutine once there is nothing further it will post. + mentionsSent := make(chan struct{}) + // Welcome message group.Go(func() error { span = sentry.StartSpan(rootSpan.Context(), "Fetch custom integration placeholders") @@ -455,6 +435,10 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat } span.Finish() + // Placeholder lookups above run in parallel with the ping; only the send + // itself has to wait, so the welcome message always ends up underneath. + <-mentionsSent + span = sentry.StartSpan(rootSpan.Context(), "Send welcome message") msgId, err := SendWelcomeMessage(ctx, cmd, ticket, subject, panel, formData, additionalPlaceholders) span.Finish() @@ -477,6 +461,10 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat // Send mentions group.Go(func() error { + // Deferred so the welcome message is released on the error paths below + // and when there is nothing to ping, not just after a successful send. + defer close(mentionsSent) + span := sentry.StartSpan(rootSpan.Context(), "Load guild metadata from database") metadata, err := dbclient.Client.GuildMetadata.Get(ctx, cmd.GuildId()) span.Finish() @@ -556,7 +544,10 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat } if content != "" { - content = fmt.Sprintf("-# ||%s||", content) + if panel == nil || panel.MentionBehaviour != "none" { + content = fmt.Sprintf("-# ||%s||", content) + } + if len(content) > 2000 { content = content[:2000] } @@ -578,7 +569,7 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat return err } - if panel != nil && panel.DeleteMentions { + if panel != nil && panel.MentionBehaviour == "delete" { span = sentry.StartSpan(rootSpan.Context(), "Delete ping message") _ = cmd.Worker().DeleteMessage(ch.Id, msg.Id) span.Finish() @@ -660,7 +651,9 @@ func checkChannelLimitAndDetermineParentId( worker *worker.Context, guildId uint64, categoryId uint64, - settings database.Settings, + overflowEnabled bool, + overflowCategoryId *uint64, + panelId int, canRetry bool, ) (uint64, error) { span := sentry.StartSpan(ctx, "Check < 500 channels") @@ -681,7 +674,7 @@ func checkChannelLimitAndDetermineParentId( return 0, err } - return checkChannelLimitAndDetermineParentId(ctx, worker, guildId, categoryId, settings, false) + return checkChannelLimitAndDetermineParentId(ctx, worker, guildId, categoryId, overflowEnabled, overflowCategoryId, panelId, false) } else { return 0, errGuildChannelLimitReached } @@ -697,9 +690,9 @@ func checkChannelLimitAndDetermineParentId( if categoryChildrenCount >= 50 { // Check if we're already in the overflow category - isOverflowCategory := settings.OverflowEnabled && - settings.OverflowCategoryId != nil && - *settings.OverflowCategoryId == categoryId + isOverflowCategory := overflowEnabled && + overflowCategoryId != nil && + *overflowCategoryId == categoryId if canRetry { canRefresh, err := redis.TakeChannelRefetchToken(ctx, guildId) @@ -712,7 +705,7 @@ func checkChannelLimitAndDetermineParentId( return 0, err } - return checkChannelLimitAndDetermineParentId(ctx, worker, guildId, categoryId, settings, false) + return checkChannelLimitAndDetermineParentId(ctx, worker, guildId, categoryId, overflowEnabled, overflowCategoryId, panelId, false) } else { // If this is the overflow category and it's full (and we can't refresh), we can't use another overflow if isOverflowCategory { @@ -722,7 +715,7 @@ func checkChannelLimitAndDetermineParentId( // If we can't refresh but overflow is available, try overflow // instead of immediately returning an error - if !settings.OverflowEnabled { + if !overflowEnabled { return 0, errCategoryChannelLimitReached } } @@ -735,19 +728,19 @@ func checkChannelLimitAndDetermineParentId( } // Try to use the overflow category if there is one - if settings.OverflowEnabled { + if overflowEnabled { // If overflow is enabled, and the category id is nil, then use the root of the server - if settings.OverflowCategoryId == nil { + if overflowCategoryId == nil { categoryId = 0 } else { - categoryId = *settings.OverflowCategoryId + categoryId = *overflowCategoryId // Verify that the overflow category still exists span := sentry.StartSpan(span.Context(), "Check if overflow category exists") if !utils.ContainsFunc(channels, func(c channel.Channel) bool { return c.Id == categoryId }) { - if err := dbclient.Client.Settings.SetOverflow(ctx, guildId, false, nil); err != nil { + if err := dbclient.Client.Panel.SetOverflow(ctx, panelId, false, nil); err != nil { return 0, err } @@ -755,7 +748,7 @@ func checkChannelLimitAndDetermineParentId( } // Check that the overflow category still has space - overflowCategoryChildrenCount := countRealChannels(channels, *settings.OverflowCategoryId) + overflowCategoryChildrenCount := countRealChannels(channels, *overflowCategoryId) if overflowCategoryChildrenCount >= 50 { return 0, errCategoryChannelLimitReached } @@ -798,22 +791,15 @@ func getTicketLimit(ctx context.Context, cmd registry.CommandContext, panel *dat group, _ := errgroup.WithContext(ctx) - // If panel has a per-panel limit, use it and count only panel tickets if panel != nil && panel.TicketLimit != nil && *panel.TicketLimit > 0 { ticketLimit = *panel.TicketLimit - group.Go(func() (err error) { openTicketCount, err = dbclient.Client.Tickets.GetOpenCountByUserAndPanel( ctx, cmd.GuildId(), cmd.UserId(), panel.PanelId) return }) } else { - // Use global limit and count all tickets - group.Go(func() (err error) { - ticketLimit, err = dbclient.Client.TicketLimit.Get(ctx, cmd.GuildId()) - return - }) - + ticketLimit = 5 group.Go(func() (err error) { openTicketCount, err = dbclient.Client.Tickets.GetOpenCountByUser(ctx, cmd.GuildId(), cmd.UserId()) return @@ -904,7 +890,7 @@ func createWebhook(ctx context.Context, c registry.CommandContext, ticketId int, return nil } -func CreateOverwrites(ctx context.Context, cmd registry.InteractionContext, userId uint64, panel *database.Panel, categoryId uint64, otherUsers ...uint64) ([]channel.PermissionOverwrite, error) { +func CreateOverwrites(ctx context.Context, cmd registry.CommandContext, userId uint64, panel *database.Panel, categoryId uint64, otherUsers ...uint64) ([]channel.PermissionOverwrite, error) { overwrites := []channel.PermissionOverwrite{ // @everyone { Id: cmd.GuildId(), @@ -915,24 +901,13 @@ func CreateOverwrites(ctx context.Context, cmd registry.InteractionContext, user } // Build permissions - additionalPermissions, err := dbclient.Client.TicketPermissions.Get(ctx, cmd.GuildId()) - if err != nil { - return nil, err - } - - // Apply panel-level grants on top of global settings (OR logic: panel can only add permissions) + var additionalPermissions database.TicketPermissions if panel != nil { - panelPerms, err := dbclient.Client.PanelTicketPermissions.Get(ctx, panel.PanelId) + var err error + additionalPermissions, err = dbclient.Client.PanelTicketPermissions.Get(ctx, panel.PanelId) if err != nil { return nil, err } - additionalPermissions.AddReactions = additionalPermissions.AddReactions || panelPerms.AddReactions - additionalPermissions.SendTTSMessages = additionalPermissions.SendTTSMessages || panelPerms.SendTTSMessages - additionalPermissions.EmbedLinks = additionalPermissions.EmbedLinks || panelPerms.EmbedLinks - additionalPermissions.AttachFiles = additionalPermissions.AttachFiles || panelPerms.AttachFiles - additionalPermissions.UseExternalEmojis = additionalPermissions.UseExternalEmojis || panelPerms.UseExternalEmojis - additionalPermissions.UseExternalStickers = additionalPermissions.UseExternalStickers || panelPerms.UseExternalStickers - additionalPermissions.SendVoiceMessages = additionalPermissions.SendVoiceMessages || panelPerms.SendVoiceMessages } // Separate permissions apply @@ -981,7 +956,7 @@ func CreateOverwrites(ctx context.Context, cmd registry.InteractionContext, user }) } - // Default team (ticket admins + ticket support) — always StandardPermissions + // Default team (ticket admins + ticket support) - always StandardPermissions if panel == nil || panel.WithDefaultTeam { supportUsers, err := dbclient.Client.Permissions.GetSupport(ctx, cmd.GuildId()) if err != nil { @@ -1019,7 +994,7 @@ func CreateOverwrites(ctx context.Context, cmd registry.InteractionContext, user } } - // Panel-specific custom teams — per-team permissions + // Panel-specific custom teams - per-team permissions if panel != nil { panelTeamIds, err := dbclient.Client.PanelTeams.GetTeamIds(ctx, panel.PanelId) if err != nil { @@ -1171,25 +1146,9 @@ func GenerateChannelName(ctx context.Context, worker *worker.Context, panel *dat // Create ticket name var name string - // Use server default naming scheme if panel == nil || panel.NamingScheme == nil { - namingScheme, err := dbclient.Client.NamingScheme.Get(ctx, guildId) - if err != nil { - return "", err - } - strTicket := strings.ToLower(i18n.GetMessageFromGuild(guildId, i18n.Ticket)) - if namingScheme == database.Username { - user, err := worker.GetUser(openerId) - - if err != nil { - return "", err - } - - name = fmt.Sprintf("%s-%s", strTicket, user.Username) - } else { - name = fmt.Sprintf("%s-%d", strTicket, ticketId) - } + name = fmt.Sprintf("%s-%d", strTicket, ticketId) } else { var err error name, err = DoSubstitutionsWithParams(worker, *panel.NamingScheme, openerId, guildId, []Substitutor{ diff --git a/bot/logic/reopen.go b/bot/logic/reopen.go index 2d3cfff3..80020a02 100644 --- a/bot/logic/reopen.go +++ b/bot/logic/reopen.go @@ -53,7 +53,6 @@ func ReopenTicket(ctx context.Context, cmd registry.CommandContext, ticketId int var openTicketCount int if panel != nil && panel.TicketLimit != nil && *panel.TicketLimit > 0 { - // Use per-panel limit and count only panel tickets ticketLimit = *panel.TicketLimit openTicketCount, err = dbclient.Client.Tickets.GetOpenCountByUserAndPanel(ctx, cmd.GuildId(), cmd.UserId(), panel.PanelId) if err != nil { @@ -61,13 +60,7 @@ func ReopenTicket(ctx context.Context, cmd registry.CommandContext, ticketId int return } } else { - // Use global limit and count all tickets - ticketLimit, err = dbclient.Client.TicketLimit.Get(ctx, cmd.GuildId()) - if err != nil { - cmd.HandleError(err) - return - } - + ticketLimit = 5 openTicketCount, err = dbclient.Client.Tickets.GetOpenCountByUser(ctx, cmd.GuildId(), cmd.UserId()) if err != nil { cmd.HandleError(err) diff --git a/bot/logic/validation.go b/bot/logic/validation.go index aa8690e1..79b038bb 100644 --- a/bot/logic/validation.go +++ b/bot/logic/validation.go @@ -15,6 +15,38 @@ import ( "github.com/TicketsBot-cloud/worker/i18n" ) +// replyIfPanelUnavailable reports whether the panel is switched off, replying with +// the reason when it is. OpenTicket repeats this check as a last line of defence; +// doing it here too means a form-backed panel is rejected on click, rather than +// after the user has filled the modal in. +func replyIfPanelUnavailable(cmd registry.InteractionContext, panel *database.Panel) (bool, error) { + if panel == nil { + return false, nil + } + + if panel.ForceDisabled { + commands, err := command.LoadCommandIds(cmd.Worker(), cmd.Worker().BotId) + if err != nil { + return true, err + } + + premiumCommand := "`/premium`" + if id, ok := commands["premium"]; ok { + premiumCommand = fmt.Sprintf("", id) + } + + cmd.Reply(customisation.Red, i18n.Error, i18n.MessageOpenPanelForceDisabled, premiumCommand) + return true, nil + } + + if panel.Disabled { + cmd.Reply(customisation.Red, i18n.Error, i18n.MessageOpenPanelDisabled) + return true, nil + } + + return false, nil +} + // ValidatePanelAccess checks if the user can access the given panel. // Returns (canProceed, outOfHoursWarningTitle, outOfHoursWarning, outOfHoursColour, error). // outOfHoursWarning is non-nil when the panel is outside support hours but the behaviour is allow_with_warning. @@ -25,6 +57,16 @@ func ValidatePanelAccess(ctx registry.InteractionContext, panel database.Panel) var outOfHoursWarningMessage *string var outOfHoursWarningColour *int + // Check the panel is switched on before anything else + unavailable, err := replyIfPanelUnavailable(ctx, &panel) + if err != nil { + return false, nil, nil, nil, err + } + + if unavailable { + return false, nil, nil, nil, nil + } + // Check support hours hasSupportHours, err := dbclient.Client.PanelSupportHours.HasSupportHours(ctx, panel.PanelId) if err != nil { diff --git a/bot/logic/welcomemessage.go b/bot/logic/welcomemessage.go index 7414a4ed..0b8a00ea 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -39,11 +39,6 @@ func SendWelcomeMessage( // Only custom integration placeholders for now - prevent making duplicate requests additionalPlaceholders map[string]string, ) (uint64, error) { - settings, err := dbclient.Client.Settings.Get(ctx, ticket.GuildId) - if err != nil { - return 0, err - } - // Build embeds welcomeMessageEmbed, err := BuildWelcomeMessageEmbed(ctx, cmd, ticket, subject, panel, additionalPlaceholders) if err != nil { @@ -69,13 +64,11 @@ func SendWelcomeMessage( embeds = append(embeds, formAnswersEmbed) } - hideClose := settings.HideCloseButton - hideCloseWithReason := settings.HideCloseWithReasonButton - hideClaim := settings.HideClaimButton + var hideClose, hideCloseWithReason, hideClaim bool if panel != nil { - hideClose = hideClose || panel.HideCloseButton - hideCloseWithReason = hideCloseWithReason || panel.HideCloseWithReasonButton - hideClaim = hideClaim || panel.HideClaimButton + hideClose = panel.HideCloseButton + hideCloseWithReason = panel.HideCloseWithReasonButton + hideClaim = panel.HideClaimButton } var buttons []component.Component @@ -138,18 +131,8 @@ func BuildWelcomeMessageEmbed( additionalPlaceholders map[string]string, ) (*embed.Embed, error) { if panel == nil || panel.WelcomeMessageEmbed == nil { - welcomeMessage, err := dbclient.Client.WelcomeMessages.Get(ctx, ticket.GuildId) - if err != nil { - return nil, err - } - - if len(welcomeMessage) == 0 { - welcomeMessage = "Thank you for contacting support.\nPlease describe your issue (and provide an invite to your server if applicable) and wait for a response." - } - - // Replace variables + welcomeMessage := "Thank you for contacting support.\nPlease describe your issue (and provide an invite to your server if applicable) and wait for a response." welcomeMessage = DoPlaceholderSubstitutions(ctx, welcomeMessage, cmd.Worker(), ticket, additionalPlaceholders) - return utils.BuildEmbedRaw(cmd.GetColour(customisation.Green), subject, welcomeMessage, nil, cmd.PremiumTier()), nil } else { data, err := dbclient.Client.Embeds.GetEmbed(ctx, *panel.WelcomeMessageEmbed) @@ -174,6 +157,27 @@ func DoPlaceholderSubstitutions( ticket database.Ticket, // Only custom integration placeholders for now - prevent making duplicate requests additionalPlaceholders map[string]string, +) string { + return doPlaceholderSubstitutions(ctx, message, worker, ticket, additionalPlaceholders, false) +} + +func DoPlainTextPlaceholderSubstitutions( + ctx context.Context, + message string, + worker *worker.Context, + ticket database.Ticket, + additionalPlaceholders map[string]string, +) string { + return doPlaceholderSubstitutions(ctx, message, worker, ticket, additionalPlaceholders, true) +} + +func doPlaceholderSubstitutions( + ctx context.Context, + message string, + worker *worker.Context, + ticket database.Ticket, + additionalPlaceholders map[string]string, + plainTextOnly bool, ) string { // Handle escaped placeholders first: \%...\% -> temporary marker escapedPlaceholderRegex := regexp.MustCompile(`\\%([a-z_]+(?::[^%\\]+)?)\\%`) @@ -189,7 +193,7 @@ func DoPlaceholderSubstitutions( }) // Process parameterized placeholders first (e.g., %date_days:30%) - message = doParameterizedSubstitutions(ctx, message, worker, ticket) + message = doParameterizedSubstitutions(ctx, message, worker, ticket, plainTextOnly) var lock sync.Mutex @@ -199,6 +203,12 @@ func DoPlaceholderSubstitutions( placeholder := placeholder f := f + if plainTextOnly { + if _, isMarkup := markupPlaceholders[placeholder]; isMarkup { + continue + } + } + formatted := fmt.Sprintf("%%%s%%", placeholder) if strings.Contains(message, formatted) { @@ -417,6 +427,7 @@ func doParameterizedSubstitutions( message string, worker *worker.Context, ticket database.Ticket, + plainTextOnly bool, ) string { // Find all parameterized placeholder matches matches := parameterizedPlaceholderRegex.FindAllStringSubmatchIndex(message, -1) @@ -435,6 +446,12 @@ func doParameterizedSubstitutions( continue } + if plainTextOnly { + if _, isMarkup := markupPlaceholders[placeholderName]; isMarkup { + continue + } + } + // Extract parameters paramString := message[match[4]:match[5]] params := strings.Split(paramString, ":") @@ -449,6 +466,24 @@ func doParameterizedSubstitutions( return message } +const AvatarUrlPlaceholder = "%avatar_url%" + +const defaultAvatarUrl = "https://cdn.discordapp.com/embed/avatars/0.png" + +var markupPlaceholders = map[string]struct{}{ + "user": {}, + "channel": {}, + "time": {}, + "date": {}, + "datetime": {}, + "discord_account_creation_date": {}, + "discord_account_age": {}, + "date_days": {}, + "date_weeks": {}, + "date_months": {}, + "date_timestamp": {}, +} + var substitutions = map[string]PlaceholderSubstitutionFunc{ "user_id": func(ctx context.Context, worker *worker.Context, ticket database.Ticket) string { return strconv.FormatUint(ticket.UserId, 10) @@ -460,7 +495,11 @@ var substitutions = map[string]PlaceholderSubstitutionFunc{ return strconv.Itoa(ticket.Id) }, "channel": func(ctx context.Context, worker *worker.Context, ticket database.Ticket) string { - return fmt.Sprintf("<#%d>", ticket.ChannelId) + if ticket.ChannelId == nil { + return "" + } + + return fmt.Sprintf("<#%d>", *ticket.ChannelId) }, "username": func(ctx context.Context, worker *worker.Context, ticket database.Ticket) string { user, _ := worker.GetUser(ticket.UserId) @@ -479,8 +518,8 @@ var substitutions = map[string]PlaceholderSubstitutionFunc{ return strconv.Itoa(len(open)) }, "total_tickets": func(ctx context.Context, _ *worker.Context, ticket database.Ticket) string { - count, _ := dbclient.Analytics.GetTotalTicketCount(ctx, ticket.GuildId) - return strconv.FormatUint(count, 10) + count, _ := dbclient.Client.Tickets.GetTotalTicketCount(ctx, ticket.GuildId) + return strconv.Itoa(count) }, "user_open_tickets": func(ctx context.Context, worker *worker.Context, ticket database.Ticket) string { count, _ := dbclient.Client.Tickets.GetOpenCountByUser(ctx, ticket.GuildId, ticket.UserId) @@ -490,19 +529,24 @@ var substitutions = map[string]PlaceholderSubstitutionFunc{ tickets, _ := dbclient.Client.Tickets.GetTotalCountByUser(ctx, ticket.GuildId, ticket.UserId) return strconv.Itoa(tickets) }, - "ticket_limit": func(ctx context.Context, worker *worker.Context, ticket database.Ticket) string { - limit, _ := dbclient.Client.TicketLimit.Get(ctx, ticket.GuildId) - return strconv.Itoa(int(limit)) + "ticket_limit": func(ctx context.Context, _ *worker.Context, ticket database.Ticket) string { + if ticket.PanelId != nil { + panel, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) + if err == nil && panel.TicketLimit != nil && *panel.TicketLimit > 0 { + return strconv.Itoa(int(*panel.TicketLimit)) + } + } + return "5" }, "rating_count": func(ctx context.Context, _ *worker.Context, ticket database.Ticket) string { ctx, cancel := context.WithTimeout(context.Background(), substitutionTimeout) defer cancel() - ratingCount, _ := dbclient.Analytics.GetFeedbackCountGuild(ctx, ticket.GuildId) - return strconv.FormatUint(ratingCount, 10) + ratingCount, _ := dbclient.Client.ServiceRatings.GetCount(ctx, ticket.GuildId) + return strconv.Itoa(ratingCount) }, "average_rating": func(ctx context.Context, _ *worker.Context, ticket database.Ticket) string { - average, _ := dbclient.Analytics.GetAverageFeedbackRatingGuild(ctx, ticket.GuildId) + average, _ := dbclient.Client.ServiceRatings.GetAverage(ctx, ticket.GuildId) return fmt.Sprintf("%.1f", average) }, "time": func(ctx context.Context, worker *worker.Context, ticket database.Ticket) string { @@ -530,7 +574,7 @@ var substitutions = map[string]PlaceholderSubstitutionFunc{ } } - data, err := dbclient.Analytics.GetFirstResponseTimeStats(ctx, ticket.GuildId) + data, err := dbclient.Client.FirstResponseTime.GetAverageTripleWindow(ctx, ticket.GuildId) if err != nil { sentry.Error(err) return "" @@ -551,7 +595,7 @@ var substitutions = map[string]PlaceholderSubstitutionFunc{ } } - data, err := dbclient.Analytics.GetFirstResponseTimeStats(ctx, ticket.GuildId) + data, err := dbclient.Client.FirstResponseTime.GetAverageTripleWindow(ctx, ticket.GuildId) if err != nil { sentry.Error(err) return "" @@ -575,7 +619,7 @@ var substitutions = map[string]PlaceholderSubstitutionFunc{ context, cancel := context.WithTimeout(context.Background(), time.Millisecond*1500) defer cancel() - data, err := dbclient.Analytics.GetFirstResponseTimeStats(context, ticket.GuildId) + data, err := dbclient.Client.FirstResponseTime.GetAverageTripleWindow(context, ticket.GuildId) if err != nil { sentry.Error(err) return "" @@ -649,6 +693,22 @@ func getFormDataFields(formData map[database.FormInput]string) []embed.EmbedFiel return fields } +const ( + embedTitleLimit = 256 + embedAuthorNameLimit = 256 + embedFooterTextLimit = 2048 + embedFieldNameLimit = 256 +) + +func truncateRunes(s string, limit int) string { + runes := []rune(s) + if len(runes) <= limit { + return s + } + + return string(runes[:limit]) +} + func BuildCustomEmbed( ctx context.Context, worker *worker.Context, ticket database.Ticket, @@ -658,15 +718,30 @@ func BuildCustomEmbed( // Only custom integration placeholders for now - prevent making duplicate requests additionalPlaceholders map[string]string, ) *embed.Embed { - description := utils.ValueOrZero(customEmbed.Description) - if ticket.Id != 0 { - description = DoPlaceholderSubstitutions(ctx, description, worker, ticket, additionalPlaceholders) + substitute := func(s string) string { + if ticket.Id == 0 { + return s + } + + return DoPlaceholderSubstitutions(ctx, s, worker, ticket, additionalPlaceholders) + } + + resolveAvatarUrl := func(url string) string { + return replaceAvatarPlaceholder(worker, ticket, url) + } + + plainTextSubstitute := func(s string, limit int) string { + if ticket.Id == 0 { + return s + } + + return truncateRunes(DoPlainTextPlaceholderSubstitutions(ctx, s, worker, ticket, additionalPlaceholders), limit) } e := &embed.Embed{ - Title: utils.ValueOrZero(customEmbed.Title), - Description: description, - Url: utils.ValueOrZero(customEmbed.Url), + Title: plainTextSubstitute(utils.ValueOrZero(customEmbed.Title), embedTitleLimit), + Description: substitute(utils.ValueOrZero(customEmbed.Description)), + Url: resolveAvatarUrl(utils.ValueOrZero(customEmbed.Url)), Timestamp: customEmbed.Timestamp, Color: int(customEmbed.Colour), } @@ -674,43 +749,54 @@ func BuildCustomEmbed( if branding { e.SetFooter(fmt.Sprintf("Powered by %s", config.Conf.Bot.PoweredBy), config.Conf.Bot.IconUrl) } else if customEmbed.FooterText != nil { - e.SetFooter(*customEmbed.FooterText, utils.ValueOrZero(customEmbed.FooterIconUrl)) + e.SetFooter( + plainTextSubstitute(*customEmbed.FooterText, embedFooterTextLimit), + resolveAvatarUrl(utils.ValueOrZero(customEmbed.FooterIconUrl)), + ) } - if customEmbed.ImageUrl != nil { - imageUrl := replaceImagePlaceholder(worker, ticket, *customEmbed.ImageUrl) + if imageUrl := resolveAvatarUrl(utils.ValueOrZero(customEmbed.ImageUrl)); imageUrl != "" { e.SetImage(imageUrl) } - if customEmbed.ThumbnailUrl != nil { - imageUrl := replaceImagePlaceholder(worker, ticket, *customEmbed.ThumbnailUrl) - e.SetThumbnail(imageUrl) + if thumbnailUrl := resolveAvatarUrl(utils.ValueOrZero(customEmbed.ThumbnailUrl)); thumbnailUrl != "" { + e.SetThumbnail(thumbnailUrl) } if customEmbed.AuthorName != nil { - e.SetAuthor(*customEmbed.AuthorName, utils.ValueOrZero(customEmbed.AuthorUrl), utils.ValueOrZero(customEmbed.AuthorIconUrl)) + if authorName := plainTextSubstitute(*customEmbed.AuthorName, embedAuthorNameLimit); authorName != "" { + e.SetAuthor( + authorName, + resolveAvatarUrl(utils.ValueOrZero(customEmbed.AuthorUrl)), + resolveAvatarUrl(utils.ValueOrZero(customEmbed.AuthorIconUrl)), + ) + } } for _, field := range fields { - value := field.Value - if ticket.Id != 0 { - value = DoPlaceholderSubstitutions(ctx, value, worker, ticket, additionalPlaceholders) + name := plainTextSubstitute(field.Name, embedFieldNameLimit) + if name == "" { + name = truncateRunes(field.Name, embedFieldNameLimit) } - e.AddField(field.Name, value, field.Inline) + e.AddField(name, substitute(field.Value), field.Inline) } return e } -func replaceImagePlaceholder(worker *worker.Context, ticket database.Ticket, imageUrl string) string { - if imageUrl != "%avatar_url%" { - return imageUrl +func replaceAvatarPlaceholder(worker *worker.Context, ticket database.Ticket, url string) string { + if url != AvatarUrlPlaceholder { + return url + } + + if ticket.UserId == 0 { + return defaultAvatarUrl } user, err := worker.GetUser(ticket.UserId) if err != nil { - return "" + return defaultAvatarUrl } return user.AvatarUrl(256) diff --git a/bot/metrics/prometheus/featureflagmetrics.go b/bot/metrics/prometheus/featureflagmetrics.go new file mode 100644 index 00000000..9c239a96 --- /dev/null +++ b/bot/metrics/prometheus/featureflagmetrics.go @@ -0,0 +1,52 @@ +package prometheus + +import ( + "time" + + "github.com/TicketsBot-cloud/worker/bot/utils" + "go.uber.org/zap" +) + +// The exposure pipeline keeps its counters as plain atomics so the common module +// needs no Prometheus dependency. These gauges sample them. +// +// FeatureFlagExposuresDropped is the one to alert on: it only moves when the +// queue is full, which means experiment data is being lost because the writer +// cannot keep up. +var ( + FeatureFlagExposuresEnqueued = newGauge("feature_flag_exposures_enqueued") + FeatureFlagExposuresSuppressedLocally = newGauge("feature_flag_exposures_suppressed_locally") + FeatureFlagExposuresSuppressedRemotely = newGauge("feature_flag_exposures_suppressed_remotely") + FeatureFlagExposuresDropped = newGauge("feature_flag_exposures_dropped") + FeatureFlagExposuresWritten = newGauge("feature_flag_exposures_written") + FeatureFlagExposureWritesFailed = newGauge("feature_flag_exposure_writes_failed") + FeatureFlagLocalCacheEntries = newGauge("feature_flag_local_cache_entries") +) + +func StartFeatureFlagMetricsLoop(logger *zap.Logger) { + if utils.ExposureRecorder == nil { + logger.Info("Exposure recorder not configured, not sampling feature flag metrics") + return + } + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + updateFeatureFlagMetrics() + + for range ticker.C { + updateFeatureFlagMetrics() + } +} + +func updateFeatureFlagMetrics() { + stats := utils.ExposureRecorder.Stats() + + FeatureFlagExposuresEnqueued.Set(float64(stats.Enqueued)) + FeatureFlagExposuresSuppressedLocally.Set(float64(stats.SuppressedLocally)) + FeatureFlagExposuresSuppressedRemotely.Set(float64(stats.SuppressedRemotely)) + FeatureFlagExposuresDropped.Set(float64(stats.Dropped)) + FeatureFlagExposuresWritten.Set(float64(stats.Written)) + FeatureFlagExposureWritesFailed.Set(float64(stats.FailedWrites)) + FeatureFlagLocalCacheEntries.Set(float64(stats.LocalCacheEntries)) +} diff --git a/bot/metrics/prometheus/productmetrics.go b/bot/metrics/prometheus/productmetrics.go new file mode 100644 index 00000000..fb0dcc1e --- /dev/null +++ b/bot/metrics/prometheus/productmetrics.go @@ -0,0 +1,64 @@ +package prometheus + +import ( + "context" + "time" + + "github.com/TicketsBot-cloud/common/sentry" + "github.com/TicketsBot-cloud/worker/bot/dbclient" + "go.uber.org/zap" +) + +func StartProductMetricsLoop(logger *zap.Logger) { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + updateProductMetrics(logger) + + for range ticker.C { + updateProductMetrics(logger) + } +} + +func updateProductMetrics(logger *zap.Logger) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + if err := dbclient.Client.AdminAnalytics.RefreshViews(ctx); err != nil { + sentry.Error(err) + logger.Error("Failed to refresh admin analytics views", zap.Error(err)) + return + } + + metrics, err := dbclient.Client.AdminAnalytics.GetGlobalUsageMetrics(ctx) + if err != nil { + sentry.Error(err) + logger.Error("Failed to read product usage metrics", zap.Error(err)) + return + } + + ProductTicketsCreatedToday.Set(float64(metrics.TicketsCreatedToday)) + ProductActiveGuildsDaily.Set(float64(metrics.ActiveGuildsDaily)) + ProductActiveGuildsWeekly.Set(float64(metrics.ActiveGuildsWeekly)) + ProductActiveGuildsMonthly.Set(float64(metrics.ActiveGuildsMonthly)) + + retention, err := dbclient.Client.AdminAnalytics.GetRetentionMetrics(ctx) + if err != nil { + sentry.Error(err) + logger.Error("Failed to read product retention metrics", zap.Error(err)) + return + } + + ProductGuildsChurned30d.Set(float64(retention.ChurnedGuilds30d)) + + adoption, err := dbclient.Client.AdminAnalytics.GetFeatureAdoption(ctx) + if err != nil { + sentry.Error(err) + logger.Error("Failed to read product adoption metrics", zap.Error(err)) + return + } + + for _, f := range adoption { + ProductFeatureAdoption.WithLabelValues(f.Feature).Set(float64(f.GuildCount)) + } +} diff --git a/bot/metrics/prometheus/prometheus.go b/bot/metrics/prometheus/prometheus.go index 3d0070b4..f37ac8b9 100644 --- a/bot/metrics/prometheus/prometheus.go +++ b/bot/metrics/prometheus/prometheus.go @@ -34,11 +34,18 @@ var ( ForwardedDashboardMessages = newCounter("forwarded_dashboard_messages") - Events = newCounterVec("events", "event_type") + Events = newCounterVec("events", "event_type") StreamBatchSize = newHistogram("stream_batch_size") StreamMessages = newHistogramVec("stream_messages", "stream") CategoryUpdates = newCounter("category_updates") + + ProductTicketsCreatedToday = newGauge("product_tickets_created_today") + ProductActiveGuildsDaily = newGauge("product_active_guilds_daily") + ProductActiveGuildsWeekly = newGauge("product_active_guilds_weekly") + ProductActiveGuildsMonthly = newGauge("product_active_guilds_monthly") + ProductGuildsChurned30d = newGauge("product_guilds_churned_30d") + ProductFeatureAdoption = newGaugeVec("product_feature_adoption", "feature") ) func newCounter(name string) prometheus.Counter { @@ -81,6 +88,14 @@ func newGauge(name string) prometheus.Gauge { }) } +func newGaugeVec(name string, labels ...string) *prometheus.GaugeVec { + return promauto.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: name, + }, labels) +} + func LogIntegrationRequest(integration database.CustomIntegration, guildId uint64) { IntegrationRequests.WithLabelValues( strconv.Itoa(integration.Id), diff --git a/bot/redis/panelcooldown.go b/bot/redis/panelcooldown.go index 4310fa70..1f639a4c 100644 --- a/bot/redis/panelcooldown.go +++ b/bot/redis/panelcooldown.go @@ -19,7 +19,7 @@ func TakePanelCooldownToken(ctx context.Context, guildId uint64, panelId int, us return true, 0, nil } - // Already on cooldown — get remaining TTL + // Already on cooldown - get remaining TTL ttl, err := Client.TTL(ctx, key).Result() if err != nil { return false, 0, err diff --git a/bot/utils/adminutils.go b/bot/utils/adminutils.go index 00c40e08..1b9e749a 100644 --- a/bot/utils/adminutils.go +++ b/bot/utils/adminutils.go @@ -1,29 +1,39 @@ package utils import ( + "context" + + "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/worker/bot/dbclient" "github.com/TicketsBot-cloud/worker/config" ) -func IsBotAdmin(id uint64) bool { - for _, admin := range config.Conf.Bot.Admins { - if admin == id { - return true - } +func IsBotOwner(id uint64) bool { + return config.Conf.Bot.Owner != 0 && config.Conf.Bot.Owner == id +} + +func IsBotAdmin(ctx context.Context, id uint64) bool { + if IsBotOwner(id) { + return true + } + + tier, err := dbclient.Client.BotStaff.GetTier(ctx, id) + if err != nil { + return false } - return false + return tier == database.BotStaffTierAdmin } -func IsBotHelper(id uint64) bool { - if IsBotAdmin(id) { +func IsBotHelper(ctx context.Context, id uint64) bool { + if IsBotOwner(id) { return true } - for _, helper := range config.Conf.Bot.Helpers { - if helper == id { - return true - } + tier, err := dbclient.Client.BotStaff.GetTier(ctx, id) + if err != nil { + return false } - return false + return tier != "" } diff --git a/bot/utils/featureflags.go b/bot/utils/featureflags.go new file mode 100644 index 00000000..92322447 --- /dev/null +++ b/bot/utils/featureflags.go @@ -0,0 +1,13 @@ +package utils + +import "github.com/TicketsBot-cloud/common/featureflags" + +// FeatureFlags is assigned once during startup, following the same pattern as +// PremiumClient. Reading it before assignment is safe: the client's methods +// tolerate a nil receiver, which is treated the same as "GrowthBook not +// configured" and evaluates every flag to enabled. +var FeatureFlags *featureflags.Client + +// ExposureRecorder is retained so its counters can be scraped and so shutdown +// can flush whatever is queued. +var ExposureRecorder *featureflags.Recorder diff --git a/bot/utils/permissionutils.go b/bot/utils/permissionutils.go index a9ea5f08..b3470d71 100644 --- a/bot/utils/permissionutils.go +++ b/bot/utils/permissionutils.go @@ -19,9 +19,16 @@ func CanClose(ctx context.Context, cmd registry.CommandContext, ticket database. } if permissionLevel == permission.Everyone { - usersCanClose, err := dbclient.Client.UsersCanClose.Get(ctx, cmd.GuildId()) - if err != nil { - cmd.HandleError(err) + 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 + } + if p.PanelId != 0 { + usersCanClose = p.UsersCanClose + } } // If they are a normal user, don't let them close if users_can_close=false, or if they are not the opener diff --git a/bot/utils/retriever.go b/bot/utils/retriever.go index 9b956c54..9160d5a2 100644 --- a/bot/utils/retriever.go +++ b/bot/utils/retriever.go @@ -30,8 +30,8 @@ func (wr WorkerRetriever) Cache() permission.PermissionCache { return permission.NewRedisCache(redis.Client) } -func (wr WorkerRetriever) IsBotAdmin(_ context.Context, userId uint64) bool { - return IsBotAdmin(userId) +func (wr WorkerRetriever) IsBotAdmin(ctx context.Context, userId uint64) bool { + return IsBotAdmin(ctx, userId) } func (wr WorkerRetriever) GetGuildOwner(ctx context.Context, guildId uint64) (uint64, error) { diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 99412d46..7313a425 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "net/http" _ "net/http/pprof" @@ -12,15 +13,19 @@ import ( "cloud.google.com/go/profiler" "github.com/TicketsBot-cloud/archiverclient" + "github.com/TicketsBot-cloud/common/featureflags" "github.com/TicketsBot-cloud/common/model" "github.com/TicketsBot-cloud/common/observability" "github.com/TicketsBot-cloud/common/premium" "github.com/TicketsBot-cloud/common/rpc" "github.com/TicketsBot-cloud/common/sentry" + "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/rest/request" "github.com/TicketsBot-cloud/worker/bot/blacklist" "github.com/TicketsBot-cloud/worker/bot/cache" "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/bot/exposures" + "github.com/TicketsBot-cloud/worker/bot/integrationowners" "github.com/TicketsBot-cloud/worker/bot/integrations" "github.com/TicketsBot-cloud/worker/bot/listeners/messagequeue" "github.com/TicketsBot-cloud/worker/bot/metrics/prometheus" @@ -106,10 +111,6 @@ func main() { cache.Client = &pgCache logger.Info("Connected to cache") - logger.Info("Connecting to clickhouse") - dbclient.ConnectAnalytics(logger.With(zap.String("service", "clickhouse"))) - logger.Info("Connected to clickhouse") - // Configure HTTP proxy if config.Conf.Discord.ProxyUrl != "" { logger.Info("Configuring REST proxy", zap.String("url", config.Conf.Discord.ProxyUrl)) @@ -132,6 +133,68 @@ func main() { []byte(config.Conf.Archiver.AesKey), ) + logger.Info("Configuring feature flags") + utils.ExposureRecorder = featureflags.NewRecorder( + logger.With(zap.String("service", "feature_flag_exposures")), + featureflags.SinkFunc(func(ctx context.Context, exposures []featureflags.RecordedExposure) error { + rows := make([]database.ExperimentExposure, 0, len(exposures)) + for _, exposure := range exposures { + rows = append(rows, database.ExperimentExposure{ + ExperimentKey: exposure.ExperimentKey, + VariationId: exposure.VariationId, + IdentifierType: exposure.IdentifierType, + Identifier: exposure.Identifier, + FeatureKey: exposure.FeatureKey, + ExposedAt: exposure.ExposedAt, + }) + } + + return dbclient.Client.ExperimentExposures.InsertBatch(ctx, rows) + }), + featureflags.NewRedisDeduper(redis.Client), + featureflags.RecorderConfig{}, + ) + + // A GrowthBook outage must not stop the worker booting, so a failure here is + // logged. New only errors on genuine misconfiguration (not on GrowthBook + // simply being unreachable, which it retries in the background). A nil + // utils.FeatureFlags evaluates every flag as enabled, which is correct when + // GrowthBook was never configured at all, but wrong when it was configured + // and construction still failed - that deployment intended to use + // GrowthBook, so it must keep failing closed like an unreachable backend + // does, not fail open. + utils.FeatureFlags, err = featureflags.New( + context.Background(), + config.Conf.FeatureFlags, + logger.With(zap.String("service", "feature_flags")), + redis.Client, + utils.ExposureRecorder, + ) + if err != nil { + logger.Error("Failed to configure feature flags", zap.Error(err)) + + if config.Conf.FeatureFlags.Attempted() { + // Some GrowthBook configuration was supplied and New still failed, so + // this is not the self-hosted "no GrowthBook at all" case - that + // includes a partial config (only one of ApiHost/ClientKey set), which + // New now rejects as an error rather than silently falling through to + // unconfigured. Using Attempted rather than Enabled here matters: Enabled + // is false for a partial config too, and gating on it would route this + // exact failure back to the fail-open default. Build a fail-closed + // client with an empty ruleset instead of leaving utils.FeatureFlags + // nil, which would otherwise evaluate every flag, including kill + // switches, as enabled. + utils.FeatureFlags, err = featureflags.NewOffline(context.Background(), logger, "{}", utils.ExposureRecorder) + if err != nil { + logger.Error("Failed to build fail-closed feature flags fallback, every flag will evaluate to enabled", zap.Error(err)) + } else { + logger.Warn("Feature flags falling back to a fail-closed client: every flag evaluates to off until this is fixed") + } + } else { + logger.Warn("Feature flags unavailable with GrowthBook not configured: every flag evaluates to enabled") + } + } + logger.Info("Starting Prometheus server") prometheus.StartServer(config.Conf.Prometheus.Address) logger.Info("Started Prometheus server") @@ -154,11 +217,16 @@ func main() { integrations.InitIntegrations() go messagequeue.ListenTicketClose() + go messagequeue.ListenTicketClaim() go messagequeue.ListenAutoClose(logger.With(zap.String("service", "autoclose"))) go messagequeue.ListenCloseRequestTimer(logger.With(zap.String("service", "close-request-timer"))) go messagequeue.ListenCloseReasonUpdate() go blacklist.StartCacheRefreshLoop(logger.With(zap.String("service", "blacklist_refresh"))) + go integrationowners.StartCacheRefreshLoop(logger.With(zap.String("service", "integration_owner_refresh"))) + go prometheus.StartProductMetricsLoop(logger.With(zap.String("service", "product_metrics"))) + go prometheus.StartFeatureFlagMetricsLoop(logger.With(zap.String("service", "feature_flag_metrics"))) + go exposures.StartRetentionLoop(logger.With(zap.String("service", "exposure_retention"))) if config.Conf.WorkerMode == config.WorkerModeInteractions { logger.Info("Starting HTTP server", zap.String("mode", string(config.Conf.WorkerMode))) @@ -195,6 +263,8 @@ func main() { return } + go messagequeue.StartCategoryUpdatePublisher(rpcClient, logger.With(zap.String("service", "category-update-publisher"))) + wg.Add(1) go func() { defer wg.Done() @@ -214,6 +284,18 @@ func main() { logger.Warn("Graceful shutdown timed out, exiting now") } + // Flush queued exposures before exit, otherwise a rolling restart silently + // discards whatever each pod had accepted but not yet written. + if err := utils.FeatureFlags.Close(); err != nil { + logger.Warn("Failed to close feature flag client", zap.Error(err)) + } + + if utils.ExposureRecorder != nil { + if err := utils.ExposureRecorder.Close(); err != nil { + logger.Warn("Failed to flush exposure recorder", zap.Error(err)) + } + } + // Flush any buffered sentry events before exit if !sentry.Flush(2 * time.Second) { logger.Warn("Sentry flush timed out, some events may be lost") diff --git a/config/config.go b/config/config.go index 48c89761..5b3298ce 100644 --- a/config/config.go +++ b/config/config.go @@ -3,6 +3,7 @@ package config import ( "time" + "github.com/TicketsBot-cloud/common/featureflags" "github.com/caarlos0/env/v10" "github.com/google/uuid" "go.uber.org/zap/zapcore" @@ -39,8 +40,7 @@ type ( IconUrl string `env:"ICON_URL" envDefault:"https://tickets.bot/assets/img/logo.png"` SupportServerInvite string `env:"SUPPORT_SERVER_INVITE" envDefault:"https://discord.gg/ticketsbot"` InviteUrl string `env:"INVITE_URL" envDefault:"https://invite.tickets.bot"` - Admins []uint64 `env:"WORKER_BOT_ADMINS"` - Helpers []uint64 `env:"WORKER_BOT_HELPERS"` + Owner uint64 `env:"WORKER_BOT_OWNER"` MonitoredBots []uint64 `env:"MONITORED_BOTS"` } @@ -72,14 +72,6 @@ type ( Threads int `env:"THREADS"` } `envPrefix:"DATABASE_"` - Clickhouse struct { - Address string `env:"ADDR"` - Threads int `env:"THREADS"` - Database string `env:"DATABASE"` - Username string `env:"USERNAME"` - Password string `env:"PASSWORD"` - } `envPrefix:"CLICKHOUSE_"` - Cache struct { Host string `env:"HOST"` Database string `env:"NAME"` @@ -95,13 +87,22 @@ type ( } `envPrefix:"WORKER_REDIS_"` Streams struct { - GoroutineLimit int `env:"STREAMS_GOROUTINE_LIMIT" envDefault:"1000"` + GoroutineLimit int `env:"STREAMS_GOROUTINE_LIMIT" envDefault:"1000"` } Prometheus struct { Address string `env:"PROMETHEUS_SERVER_ADDR"` } + // FeatureFlags carries its own fully qualified GROWTHBOOK_* env tags, so + // it needs no envPrefix here. Leaving it unset disables flag evaluation + // rather than failing startup. + FeatureFlags featureflags.Config + + // ExperimentExposureRetention bounds the growth of experiment_exposures. + // Zero or negative disables the purge. Default is 90 days. + ExperimentExposureRetention time.Duration `env:"EXPERIMENT_EXPOSURE_RETENTION" envDefault:"2160h"` + Statsd struct { Address string `env:"ADDR"` Prefix string `env:"PREFIX"` diff --git a/event/caller.go b/event/caller.go index 50dc25bd..761e72a9 100644 --- a/event/caller.go +++ b/event/caller.go @@ -5,21 +5,20 @@ package event import ( "fmt" - "strconv" - "github.com/TicketsBot-cloud/gdl/objects/interaction" "github.com/TicketsBot-cloud/worker/bot/command" cmdcontext "github.com/TicketsBot-cloud/worker/bot/command/context" "github.com/TicketsBot-cloud/worker/bot/command/impl/admin" "github.com/TicketsBot-cloud/worker/bot/command/impl/admin/debug" "github.com/TicketsBot-cloud/worker/bot/command/impl/general" + "github.com/TicketsBot-cloud/worker/bot/command/impl/kb" "github.com/TicketsBot-cloud/worker/bot/command/impl/settings" - "github.com/TicketsBot-cloud/worker/bot/command/impl/settings/setup" "github.com/TicketsBot-cloud/worker/bot/command/impl/statistics" "github.com/TicketsBot-cloud/worker/bot/command/impl/tags" "github.com/TicketsBot-cloud/worker/bot/command/impl/tickets" "github.com/TicketsBot-cloud/worker/bot/command/registry" "github.com/pkg/errors" + "strconv" ) var ErrArgumentNotFound = errors.New("argument not found") @@ -134,17 +133,17 @@ func callCommand( v.Execute(ctx, arg0) case admin.AdminRecacheCommand: - var arg0 *string + var arg0 string opt0, ok0 := findOption(cmd.Properties().Arguments[0], options) if !ok0 { - arg0 = nil + return ErrArgumentNotFound } else { argValue, ok := opt0.Value.(string) if !ok { return fmt.Errorf("option %s was not a string", opt0.Name) } - arg0 = &argValue + arg0 = argValue } v.Execute(ctx, arg0) @@ -243,6 +242,54 @@ func callCommand( case general.VoteCommand: v.Execute(ctx) + case kb.KBBrowseCommand: + var arg0 *string + + opt0, ok0 := findOption(cmd.Properties().Arguments[0], options) + if !ok0 { + arg0 = nil + } else { + argValue, ok := opt0.Value.(string) + if !ok { + return fmt.Errorf("option %s was not a string", opt0.Name) + } + arg0 = &argValue + } + + v.Execute(ctx, arg0) + case kb.KBCommand: + + v.Execute(ctx) + case kb.KBSearchCommand: + var arg0 string + + opt0, ok0 := findOption(cmd.Properties().Arguments[0], options) + if !ok0 { + return ErrArgumentNotFound + } else { + argValue, ok := opt0.Value.(string) + if !ok { + return fmt.Errorf("option %s was not a string", opt0.Name) + } + arg0 = argValue + } + + v.Execute(ctx, arg0) + case kb.KBSendCommand: + var arg0 string + + opt0, ok0 := findOption(cmd.Properties().Arguments[0], options) + if !ok0 { + return ErrArgumentNotFound + } else { + argValue, ok := opt0.Value.(string) + if !ok { + return fmt.Errorf("option %s was not a string", opt0.Name) + } + arg0 = argValue + } + + v.Execute(ctx, arg0) case settings.AddAdminCommand: var arg0 uint64 @@ -361,83 +408,12 @@ func callCommand( } v.Execute(ctx, arg0) - case settings.ViewStaffCommand: + case settings.SetupCommand: v.Execute(ctx) - case setup.AutoSetupCommand: - - v.Execute(ctx) - case setup.LimitSetupCommand: - var arg0 int - - opt0, ok0 := findOption(cmd.Properties().Arguments[0], options) - if !ok0 { - return ErrArgumentNotFound - } else { - argValue, ok := opt0.Value.(float64) - if !ok { - return fmt.Errorf("option %s was not a float64", opt0.Name) - } - arg0 = int(argValue) - } - - v.Execute(ctx, arg0) - case setup.SetupCommand: + case settings.ViewStaffCommand: v.Execute(ctx) - case setup.ThreadsSetupCommand: - var arg0 bool - - opt0, ok0 := findOption(cmd.Properties().Arguments[0], options) - if !ok0 { - return ErrArgumentNotFound - } else { - argValue, ok := opt0.Value.(bool) - if !ok { - return fmt.Errorf("option %s was not a bool", opt0.Name) - } - arg0 = argValue - - } - var arg1 *uint64 - - opt1, ok1 := findOption(cmd.Properties().Arguments[1], options) - if !ok1 { - arg1 = nil - } else { - raw, ok := opt1.Value.(string) - if !ok { - return fmt.Errorf("option %s was not a snowflake", opt1.Name) - } - - argValue, err := strconv.ParseUint(raw, 10, 64) - if err != nil { - return fmt.Errorf("option %s was not a valid snowflake", opt1.Name) - } - arg1 = &argValue - } - - v.Execute(ctx, arg0, arg1) - case setup.TranscriptsSetupCommand: - var arg0 uint64 - - opt0, ok0 := findOption(cmd.Properties().Arguments[0], options) - if !ok0 { - return ErrArgumentNotFound - } else { - raw, ok := opt0.Value.(string) - if !ok { - return fmt.Errorf("option %s was not a snowflake", opt0.Name) - } - - argValue, err := strconv.ParseUint(raw, 10, 64) - if err != nil { - return fmt.Errorf("option %s was not a valid snowflake", opt0.Name) - } - arg0 = argValue - } - - v.Execute(ctx, arg0) case statistics.StatsCommand: v.Execute(ctx) @@ -603,17 +579,17 @@ func callCommand( v.Execute(ctx) case tickets.OpenCommand: - var arg0 *string + var arg0 string opt0, ok0 := findOption(cmd.Properties().Arguments[0], options) if !ok0 { - arg0 = nil + return ErrArgumentNotFound } else { argValue, ok := opt0.Value.(string) if !ok { return fmt.Errorf("option %s was not a string", opt0.Name) } - arg0 = &argValue + arg0 = argValue } v.Execute(ctx, arg0) diff --git a/event/commandexecutor.go b/event/commandexecutor.go index 959add7f..d0472f79 100644 --- a/event/commandexecutor.go +++ b/event/commandexecutor.go @@ -187,12 +187,12 @@ func executeCommand( return } - if properties.AdminOnly && !utils.IsBotAdmin(interactionContext.UserId()) { + if properties.AdminOnly && !utils.IsBotAdmin(ctx, interactionContext.UserId()) { interactionContext.Reply(customisation.Red, i18n.Error, i18n.MessageOwnerOnly) return } - if properties.HelperOnly && !utils.IsBotHelper(interactionContext.UserId()) { + if properties.HelperOnly && !utils.IsBotHelper(ctx, interactionContext.UserId()) { interactionContext.Reply(customisation.Red, i18n.Error, i18n.MessageNoPermission) return } diff --git a/event/httplisten.go b/event/httplisten.go index 1db5de7f..047f5ddd 100644 --- a/event/httplisten.go +++ b/event/httplisten.go @@ -2,6 +2,7 @@ package event import ( "context" + stdjson "encoding/json" "fmt" "strings" "time" @@ -271,11 +272,21 @@ func interactionHandler(redis *redis.Client, cache *cache.PgCache) func(*gin.Con return } - ctx.JSON(200, interaction.NewResponseDeferredMessageUpdate()) + var probe struct { + Message *stdjson.RawMessage `json:"message"` + } + _ = json.Unmarshal(payload.Event, &probe) + hasSourceMessage := probe.Message != nil + + if hasSourceMessage { + ctx.JSON(200, interaction.NewResponseDeferredMessageUpdate()) + } else { + ctx.JSON(200, interaction.NewResponseAckWithSource(message.SumFlags(message.FlagEphemeral))) + } ctx.Writer.Flush() responseCh := make(chan button.Response, 1) - btn_manager.HandleModalInteraction(ctx, buttonManager, worker, interactionData, responseCh) + btn_manager.HandleModalInteraction(ctx, buttonManager, worker, interactionData, responseCh, hasSourceMessage) go handleButtonResponseAfterDefer(interactionData.InteractionMetadata, worker, time.Now(), responseCh) } @@ -341,7 +352,7 @@ func handleApplicationCommandResponseAfterDefer(interactionData interaction.Appl } } case command.CommandResponseTypeModal: - // Modals cannot be sent after a defer — they must be the immediate interaction response + // Modals cannot be sent after a defer - they must be the immediate interaction response sentry.ErrorWithContext(fmt.Errorf("attempted to send modal after defer"), NewApplicationCommandInteractionErrorContext(interactionData)) } } diff --git a/experiments/experiments.go b/experiments/experiments.go index 9c491238..46b47fa3 100644 --- a/experiments/experiments.go +++ b/experiments/experiments.go @@ -13,13 +13,9 @@ import ( type Experiment string -const ( - COMPONENTS_V2_STATISTICS Experiment = "COMPONENTS_V2_STATISTICS" -) +const () -var List = []Experiment{ - COMPONENTS_V2_STATISTICS, -} +var List = []Experiment{} func HasFeature(ctx context.Context, guildId uint64, experiment Experiment) bool { if os.Getenv("ENABLE_ALL_EXPERIMENTS") == "true" { diff --git a/go.mod b/go.mod index 9a44e6b1..3bcf40db 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,9 @@ module github.com/TicketsBot-cloud/worker go 1.25.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 @@ -14,10 +14,9 @@ go 1.25.0 require ( cloud.google.com/go/profiler v0.4.2 - github.com/TicketsBot-cloud/analytics-client v0.0.0-20250604180646-6606dfc8fc8c github.com/TicketsBot-cloud/archiverclient v0.0.0-20251015181023-f0b66a074704 - github.com/TicketsBot-cloud/common v0.0.0-20260620182815-55fda9a14c01 - github.com/TicketsBot-cloud/database v0.0.0-20260423165031-495c2e8a5bc7 + github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e + github.com/TicketsBot-cloud/database v0.0.0-20260827064551-53077b598c5f github.com/TicketsBot-cloud/gdl v0.0.0-20260426095953-999472e6e538 github.com/caarlos0/env/v10 v10.0.0 github.com/elliotchance/orderedmap v1.8.0 @@ -36,11 +35,11 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 go.uber.org/atomic v1.11.0 - go.uber.org/zap v1.27.1 + go.uber.org/zap v1.28.0 golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 - golang.org/x/net v0.52.0 - golang.org/x/sync v0.20.0 - golang.org/x/tools v0.43.0 + golang.org/x/net v0.57.0 + golang.org/x/sync v0.22.0 + golang.org/x/tools v0.48.0 gopkg.in/alexcesaro/statsd.v2 v2.0.0 ) @@ -49,12 +48,9 @@ require ( cloud.google.com/go/auth v0.16.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.6.0 // indirect - github.com/ClickHouse/ch-go v0.66.0 // indirect - github.com/ClickHouse/clickhouse-go/v2 v2.36.0 // indirect github.com/TicketsBot-cloud/logarchiver v0.0.0-20251018211319-7a7df5cacbdc // indirect github.com/TicketsBot/common v0.0.0-20240613013221-1e27eb8bfe37 // indirect github.com/TicketsBot/ttlcache v1.6.1-0.20200405150101-acc18e37b261 // indirect - github.com/andybalholm/brotli v1.1.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/boltdb/bolt v1.3.1 // indirect github.com/bytedance/sonic v1.13.2 // indirect @@ -69,8 +65,6 @@ require ( github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-errors/errors v1.5.1 // indirect - github.com/go-faster/city v1.0.1 // indirect - github.com/go-faster/errors v0.7.1 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -83,6 +77,7 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect + github.com/growthbook/growthbook-golang v0.2.9 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect @@ -111,10 +106,8 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/panjf2000/ants/v2 v2.11.3 // indirect github.com/pasztorpisti/qs v0.0.0-20171216220353-8d6c33ee906c // indirect - github.com/paulmach/orb v0.11.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/philhofer/fwd v1.2.0 // indirect - github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.63.0 // indirect @@ -122,10 +115,10 @@ require ( 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/segmentio/asm v1.2.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/tatsuworks/czlib v0.0.0-20190916144400-8a51758ea0d9 // indirect github.com/tinylib/msgp v1.4.0 // indirect + github.com/tmaxmax/go-sse v0.10.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect @@ -135,12 +128,12 @@ require ( go.opentelemetry.io/otel/trace v1.36.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.17.0 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/mod v0.34.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/mod v0.38.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/term v0.42.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.11.0 // indirect google.golang.org/api v0.232.0 // indirect google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect diff --git a/go.sum b/go.sum index 3fcf4548..7acfe894 100644 --- a/go.sum +++ b/go.sum @@ -17,10 +17,6 @@ cloud.google.com/go/profiler v0.4.2/go.mod h1:7GcWzs9deJHHdJ5J9V1DzKQ9JoIoTGhezw cloud.google.com/go/storage v1.52.0 h1:ROpzMW/IwipKtatA69ikxibdzQSiXJrY9f6IgBa9AlA= cloud.google.com/go/storage v1.52.0/go.mod h1:4wrBAbAYUvYkbrf19ahGm4I5kDQhESSqN3CGEkMGvOY= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/ClickHouse/ch-go v0.66.0 h1:hLslxxAVb2PHpbHr4n0d6aP8CEIpUYGMVT1Yj/Q5Img= -github.com/ClickHouse/ch-go v0.66.0/go.mod h1:noiHWyLMJAZ5wYuq3R/K0TcRhrNA8h7o1AqHX0klEhM= -github.com/ClickHouse/clickhouse-go/v2 v2.36.0 h1:FJ03h8VdmBUhvR9nQEu5jRLdfG0c/HSxUjiNdOxRQww= -github.com/ClickHouse/clickhouse-go/v2 v2.36.0/go.mod h1:aijX64fKD1hAWu/zqWEmiGk7wRE8ZnpN0M3UvjsZG3I= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= @@ -29,14 +25,12 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/ReneKroon/ttlcache v1.6.0/go.mod h1:DG6nbhXKUQhrExfwwLuZUdH7UnRDDRA1IW+nBuCssvs= -github.com/TicketsBot-cloud/analytics-client v0.0.0-20250604180646-6606dfc8fc8c h1:0pKR6drN8yc7dSQJcoNop9G5ywuzTZzLgD9Ktp7AvJo= -github.com/TicketsBot-cloud/analytics-client v0.0.0-20250604180646-6606dfc8fc8c/go.mod h1:zecIz09jVDSHyhV6NYgTko0NEN0QJGiZbzcxHRjQLzc= github.com/TicketsBot-cloud/archiverclient v0.0.0-20251015181023-f0b66a074704 h1:liLfvCrzoJ89DXFHzsd1iK3cyP8s4i0CnZPRFEj53zg= github.com/TicketsBot-cloud/archiverclient v0.0.0-20251015181023-f0b66a074704/go.mod h1:Mux1bEPpOHwRw1wo6Fa6qJLJH9Erk9qv1yAIfLi1Wmw= -github.com/TicketsBot-cloud/common v0.0.0-20260620182815-55fda9a14c01 h1:BTBphQxBilbCjiNUYHd67kG5yCdEQ9YRAvzRBxOgjkA= -github.com/TicketsBot-cloud/common v0.0.0-20260620182815-55fda9a14c01/go.mod h1:IA3watwJMLySOt5tsO5GPwAsHAFw6x9aqchxNWUqlog= -github.com/TicketsBot-cloud/database v0.0.0-20260423165031-495c2e8a5bc7 h1:35PmSSlrSN+DOLNEnkRw4vSg1qxnnlEKtMlOaOUjMOQ= -github.com/TicketsBot-cloud/database v0.0.0-20260423165031-495c2e8a5bc7/go.mod h1:HQXAgmNSm7/FmBYwcsa6qpZqMrDhbLoEl+AyqFQ+RwY= +github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e h1:66mDH3lvotbWSgQXNVbssCvUgy6Yg1mqKl7RmEdr6y4= +github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e/go.mod h1:yL+VPSYNVK5gxUkA+fbb0WdTjGfWXnr7ScfL0rwk29g= +github.com/TicketsBot-cloud/database v0.0.0-20260827064551-53077b598c5f h1:5dcXYEUJKRvB+csN5mbTycftijY9NAIhmV4EtuYoNok= +github.com/TicketsBot-cloud/database v0.0.0-20260827064551-53077b598c5f/go.mod h1:HQXAgmNSm7/FmBYwcsa6qpZqMrDhbLoEl+AyqFQ+RwY= github.com/TicketsBot-cloud/gdl v0.0.0-20260426095953-999472e6e538 h1:ewKw1Wv1x/yi8h1IH7EofcYnPUxWZaVTIPFkT97nhn0= github.com/TicketsBot-cloud/gdl v0.0.0-20260426095953-999472e6e538/go.mod h1:CdwBR2egPtxUXjD2CgC9ZwfuB8dz9HPePM8nuG6dt7Y= github.com/TicketsBot-cloud/logarchiver v0.0.0-20251018211319-7a7df5cacbdc h1:qTLNpCvIqM7UwZ6MdWQ9EztcDsIJfHh+VJdG+ULLEaA= @@ -45,8 +39,6 @@ github.com/TicketsBot/common v0.0.0-20240613013221-1e27eb8bfe37 h1:NC5fn+uAup0Jx github.com/TicketsBot/common v0.0.0-20240613013221-1e27eb8bfe37/go.mod h1:UZ6Kzobh9akWyon7iGLPb4w/9gmKV+sLuR6PmthsS+U= github.com/TicketsBot/ttlcache v1.6.1-0.20200405150101-acc18e37b261 h1:NHD5GB6cjlkpZFjC76Yli2S63/J2nhr8MuE6KlYJpQM= github.com/TicketsBot/ttlcache v1.6.1-0.20200405150101-acc18e37b261/go.mod h1:2zPxDAN2TAPpxUPjxszjs3QFKreKrQh5al/R3cMXmYk= -github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= -github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= @@ -102,10 +94,6 @@ github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= -github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= -github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= -github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= @@ -138,17 +126,12 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -163,6 +146,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/growthbook/growthbook-golang v0.2.9 h1:J/HGjxhHFgGtpEu/VJmGnxbTJjAeYZtb2qsLClrtdjo= +github.com/growthbook/growthbook-golang v0.2.9/go.mod h1:mY8oBSateRALL7hMwr8UaPmsdm+10ffmgWIT1N5iQZE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -234,9 +219,7 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -285,7 +268,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= @@ -298,15 +280,10 @@ github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZ github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek= github.com/pasztorpisti/qs v0.0.0-20171216220353-8d6c33ee906c h1:Gcce/r5tSQeprxswXXOwQ/RBU1bjQWVd9dB7QKoPXBE= github.com/pasztorpisti/qs v0.0.0-20171216220353-8d6c33ee906c/go.mod h1:1iCZ0433JJMecYqCa+TdWA9Pax8MGl4ByuNDZ7eSnQY= -github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= -github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= -github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= -github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= -github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -342,8 +319,6 @@ github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThC github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= -github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= @@ -363,7 +338,6 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -374,26 +348,18 @@ github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203 h1:QVqDTf3h2WHt08Yu github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKNihBbwlX8dLpwxCl3+HnXKV/R0e+sRLd9C8= github.com/tatsuworks/czlib v0.0.0-20190916144400-8a51758ea0d9 h1:i2aD44Moa5N5pt/WNwHLvIklzPymtr8vkkBlVdNElUE= github.com/tatsuworks/czlib v0.0.0-20190916144400-8a51758ea0d9/go.mod h1:6HrfShlf4bKeQEFdWn4JP/yet/mHW2RhxOQf0e3HWA0= -github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8= github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= +github.com/tmaxmax/go-sse v0.10.0 h1:j9F93WB4Hxt8wUf6oGffMm4dutALvUPoDDxfuDQOSqA= +github.com/tmaxmax/go-sse v0.10.0/go.mod h1:u/2kZQR1tyngo1lKaNCj1mJmhXGZWS1Zs5yiSOD+Eg8= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= -github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= -github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= -github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= -go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA= @@ -430,8 +396,10 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU= golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -444,46 +412,37 @@ golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -494,9 +453,7 @@ golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -505,16 +462,16 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -524,8 +481,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -537,17 +494,14 @@ golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.232.0 h1:qGnmaIMf7KcuwHOlF3mERVzChloDYwRfOJOrHt8YC3I= google.golang.org/api v0.232.0/go.mod h1:p9QCfBWZk1IJETUdbTKloR5ToFdKbYh2fkjsUL6vNoY= @@ -559,8 +513,6 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/alexcesaro/statsd.v2 v2.0.0 h1:FXkZSCZIH17vLCO5sO2UucTHsH9pc+17F6pl3JVCwMc= diff --git a/i18n/messages.go b/i18n/messages.go index 1a21ff6b..bda6bf28 100644 --- a/i18n/messages.go +++ b/i18n/messages.go @@ -95,6 +95,7 @@ var ( MessageOpenPanelCooldown MessageId = "open.panel_cooldown" MessageOpenPanelForceDisabled MessageId = "open.panel_force_disabled" MessageOpenPanelDisabled MessageId = "open.panel_disabled" + MessageOpenFeatureUnavailable MessageId = "open.feature_unavailable" MessageOutsideSupportHoursTitle MessageId = "open.outside_support_hours.title" MessageOutsideSupportHours MessageId = "open.outside_support_hours.message" MessageTicketOpened MessageId = "open.success" @@ -132,6 +133,7 @@ var ( MessageBlacklistRemoveRole MessageId = "commands.blacklist.remove_role.success" MessageClaimed MessageId = "commands.claim.success" + MessageAlreadyClaimed MessageId = "commands.claim.already_claimed" MessageClaimNoPermission MessageId = "commands.claim.no_permission" MessageClaimThread MessageId = "commands.claim.thread" @@ -203,8 +205,9 @@ var ( MessageOpenCantMessageInThreads MessageId = "commands.open.threads.cant_message_in_threads" MessageCloseRequested MessageId = "commands.close_request.success" - MessageCloseRequestNoReason MessageId = "commands.close_request.no_reason" - MessageCloseRequestWithReason MessageId = "commands.close_request.with_reason" + MessageCloseRequestCloseAt MessageId = "commands.close_request.close_at" + MessageCloseRequestIntro MessageId = "commands.close_request.intro" + MessageCloseRequestPrompt MessageId = "commands.close_request.prompt" MessageCloseRequestNoPermission MessageId = "commands.close_request.no_permission" MessageCloseRequestDenied MessageId = "commands.close_request.denied" MessageCloseRequestAccept MessageId = "commands.close_request.accept" @@ -366,6 +369,27 @@ var ( HelpOnCall MessageId = "help.on_call" HelpGdpr MessageId = "help.gdpr" HelpEdit MessageId = "help.edit" + HelpKb MessageId = "help.kb" + HelpKbSearch MessageId = "help.kb.search" + HelpKbBrowse MessageId = "help.kb.browse" + HelpKbSend MessageId = "help.kb.send" + + MessageKbNoArticlesFound MessageId = "commands.kb.no_articles_found" + MessageKbSearchResults MessageId = "commands.kb.search_results" + MessageKbSelectCategory MessageId = "commands.kb.select_category" + MessageKbArticleSent MessageId = "commands.kb.article_sent" + MessageKbArticleNotFound MessageId = "commands.kb.article_not_found" + MessageKbNoCategories MessageId = "commands.kb.no_categories" + MessageKbSuggestTitle MessageId = "commands.kb.suggest_title" + MessageKbSuggestFooter MessageId = "commands.kb.suggest_footer" + MessageKbCreateTicket MessageId = "commands.kb.create_ticket" + MessageKbRead MessageId = "commands.kb.read" + MessageKbBack MessageId = "commands.kb.back" + MessageKbMoreArticles MessageId = "commands.kb.more_articles" + MessageKbNoDescription MessageId = "commands.kb.no_description" + MessageKbFeedbackHelpful MessageId = "commands.kb.feedback_helpful" + MessageKbFeedbackThanks MessageId = "commands.kb.feedback_thanks" + MessageKbFeedbackThanksBody MessageId = "commands.kb.feedback_thanks_body" GdprIntro MessageId = "gdpr.intro" GdprTranscriptSectionTitle MessageId = "gdpr.section.transcript" diff --git a/locale b/locale index 6d572d7e..087963f3 160000 --- a/locale +++ b/locale @@ -1 +1 @@ -Subproject commit 6d572d7ee1353b6d0f084a4e8574d0b0d8d7fd40 +Subproject commit 087963f3a474f70d655383103cb044881603e071