From 68bde660845850c7b781c438b218b066208c8eaf Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 7 Apr 2026 18:17:08 +0100 Subject: [PATCH 01/35] kb Signed-off-by: Ben --- bot/command/impl/tags/tag.go | 14 ++++++++++++++ go.mod | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) 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/go.mod b/go.mod index f3bc8dea..16d11e67 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.0 toolchain go1.24.2 -//replace github.com/TicketsBot-cloud/database => ../database +replace github.com/TicketsBot-cloud/database => ../database //replace github.com/TicketsBot-cloud/common => ../common From b2e381584c04e850173a6b37e3c6aa1ef78c8ee5 Mon Sep 17 00:00:00 2001 From: biast12 Date: Thu, 23 Apr 2026 20:08:27 +0200 Subject: [PATCH 02/35] Remove global panel settings Switch ticket behavior to prefer per-panel configuration instead of global settings. Key changes: - Use panel.TicketNotificationChannel, panel.UseThreads, panel.TranscriptChannelId and panel-level ticket permissions (PanelTicketPermissions) throughout (open/close/claim/reopen/startticket/add/switchpanel/etc.) instead of relying on global Settings. - Update add/remove admin/support flows to apply permission edits across each panel's notification channel (deduplicating by channel). - Remove global thread/transcript setup commands and related global handling; admin debug permission checks now operate on panels directly. - Open command: add panel autocomplete, split logic to open with a selected panel, and export BuildFormModal for reuse by slash contexts. - Default behaviors: fallback ticket limit set to 5 when no per-panel limit, default welcome message and simplified naming when not configured. - Misc: avoid deleting archive-channel DB entry on channel delete, adjust listeners to use panel channels, and various import/usage updates to database/sentry/handlers. These changes consolidate configuration to the panel level and prepare for per-panel overrides of notification channels, threads, transcripts and permissions. --- bot/button/handlers/addadmin.go | 36 +++--- bot/button/handlers/addsupport.go | 53 ++++---- .../admindebug/server/modals/permissions.go | 20 +-- bot/button/handlers/panel.go | 15 ++- bot/command/context/replyable.go | 18 +-- bot/command/impl/admin/debug/debugserver.go | 36 +----- bot/command/impl/settings/removeadmin.go | 37 +++--- bot/command/impl/settings/removesupport.go | 36 +++--- bot/command/impl/settings/setup/auto.go | 4 - bot/command/impl/settings/setup/setup.go | 2 - bot/command/impl/settings/setup/threads.go | 70 ---------- .../impl/settings/setup/transcripts.go | 54 -------- bot/command/impl/tickets/add.go | 25 ++-- bot/command/impl/tickets/oncall.go | 12 +- bot/command/impl/tickets/open.go | 121 ++++++++++++++++-- bot/command/impl/tickets/startticket.go | 9 +- bot/command/impl/tickets/switchpanel.go | 13 +- bot/listeners/channeldelete.go | 6 - bot/listeners/threadmembersupdate.go | 10 +- bot/listeners/threadupdate.go | 10 +- bot/logic/claim.go | 9 +- bot/logic/close.go | 17 +-- bot/logic/open.go | 75 ++--------- bot/logic/reopen.go | 9 +- bot/logic/welcomemessage.go | 38 ++---- event/caller.go | 67 +--------- 26 files changed, 302 insertions(+), 500 deletions(-) delete mode 100644 bot/command/impl/settings/setup/threads.go delete mode 100644 bot/command/impl/settings/setup/transcripts.go 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/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/panel.go b/bot/button/handlers/panel.go index 2b1ede45..405e70fc 100644 --- a/bot/button/handlers/panel.go +++ b/bot/button/handlers/panel.go @@ -227,10 +227,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/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/debug/debugserver.go b/bot/command/impl/admin/debug/debugserver.go index 2152416f..700489ec 100644 --- a/bot/command/impl/admin/debug/debugserver.go +++ b/bot/command/impl/admin/debug/debugserver.go @@ -198,19 +198,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" @@ -289,37 +276,24 @@ 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/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..b60b2062 100644 --- a/bot/command/impl/settings/removesupport.go +++ b/bot/command/impl/settings/removesupport.go @@ -49,11 +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 { @@ -124,12 +119,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 +146,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/auto.go index b3a890c6..f577e781 100644 --- a/bot/command/impl/settings/setup/auto.go +++ b/bot/command/impl/settings/setup/auto.go @@ -93,10 +93,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)) diff --git a/bot/command/impl/settings/setup/setup.go b/bot/command/impl/settings/setup/setup.go index 24c7e9ec..5775ec23 100644 --- a/bot/command/impl/settings/setup/setup.go +++ b/bot/command/impl/settings/setup/setup.go @@ -21,8 +21,6 @@ func (SetupCommand) Properties() registry.Properties { Children: []registry.Command{ AutoSetupCommand{}, LimitSetupCommand{}, - TranscriptsSetupCommand{}, - ThreadsSetupCommand{}, }, } } 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/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/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..20a0c512 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,7 +32,7 @@ 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, Timeout: constants.TimeoutOpenTicket, @@ -35,22 +43,111 @@ 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 settings.DisableOpenCommand { - ctx.Reply(customisation.Red, i18n.Error, i18n.MessageOpenCommandDisabled, "/open") + if !ok { + ctx.ReplyRaw(customisation.Red, "Error", "Panel not found.") return } - var subject string - if providedSubject != nil { - subject = *providedSubject + 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 } - logic.OpenTicket(ctx.Context, ctx, nil, subject, nil, nil, nil, nil) + 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) + return + } + + 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 + } + + if len(inputs) == 0 { + logic.OpenTicket(ctx.Context, ctx, &panel, panel.Title, nil, outOfHoursTitle, outOfHoursWarning, outOfHoursColour) + } 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..b7e2c17f 100644 --- a/bot/command/impl/tickets/startticket.go +++ b/bot/command/impl/tickets/startticket.go @@ -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 bacc0094..efe8c69b 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/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/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 a580ad1f..70de2d8f 100644 --- a/bot/logic/claim.go +++ b/bot/logic/claim.go @@ -123,9 +123,12 @@ 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 + var additionalPermissions database.TicketPermissions + if ticket.PanelId != nil { + additionalPermissions, err = dbclient.Client.PanelTicketPermissions.Get(ctx, *ticket.PanelId) + if err != nil { + return nil, err + } } integrationRoleId, err := GetIntegrationRoleId(ctx, worker, ticket.GuildId) diff --git a/bot/logic/close.go b/bot/logic/close.go index 4c40c367..26743865 100644 --- a/bot/logic/close.go +++ b/bot/logic/close.go @@ -287,10 +287,8 @@ func CloseTicket(ctx context.Context, cmd registry.CommandContext, reason *strin } } - if panel != nil && panel.TicketNotificationChannel != nil { + if panel != nil { notificationChannel = panel.TicketNotificationChannel - } else if settings.TicketNotificationChannel != nil { - notificationChannel = settings.TicketNotificationChannel } if notificationChannel != nil { @@ -305,23 +303,18 @@ func CloseTicket(ctx context.Context, cmd registry.CommandContext, reason *strin } 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 + // Send logs to archive channel (per-panel transcript channel only) var archiveChannelId *uint64 if ticket.PanelId != nil { - acId, err := dbclient.Client.ArchiveChannel.GetByPanel(ctx, ticket.GuildId, *ticket.PanelId) + p, err := dbclient.Client.Panel.GetById(ctx, *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 + if p.PanelId != 0 { + archiveChannelId = p.TranscriptChannelId } - archiveChannelId = acId } var archiveChannelExists bool diff --git a/bot/logic/open.go b/bot/logic/open.go index ccdb3aa5..35689aad 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -164,12 +164,8 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat } 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 - } + // Determine if we should use threads; panel-less tickets always use channel mode + isThread := panel != nil && panel.UseThreads // Check if the parent channel is an announcement channel span = sentry.StartSpan(rootSpan.Context(), "Check if parent channel is announcement channel") @@ -199,15 +195,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 +208,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() } @@ -321,13 +304,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 { @@ -798,22 +777,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 @@ -915,24 +887,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 @@ -1171,25 +1132,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/welcomemessage.go b/bot/logic/welcomemessage.go index 9057cd4c..b7f0ab65 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -40,11 +40,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 { @@ -70,13 +65,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 @@ -139,18 +132,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) @@ -533,9 +516,14 @@ 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) diff --git a/event/caller.go b/event/caller.go index 50dc25bd..3f19cbf5 100644 --- a/event/caller.go +++ b/event/caller.go @@ -385,59 +385,6 @@ func callCommand( case setup.SetupCommand: 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 +550,13 @@ func callCommand( v.Execute(ctx) case tickets.OpenCommand: - 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 + return ErrArgumentNotFound + } + arg0, ok := opt0.Value.(string) + if !ok { + return fmt.Errorf("option %s was not a string", opt0.Name) } v.Execute(ctx, arg0) From 8d50aa79eac66c288f259fa86e2a327977132163 Mon Sep 17 00:00:00 2001 From: biast12 Date: Thu, 23 Apr 2026 20:35:42 +0200 Subject: [PATCH 03/35] Remove ticket limit setup and references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the ticket limit setup command and remove related DB usage and UI references. Removed LimitSetupCommand (deleted limit.go) and removed it from the setup command children. Stop persisting the created category ID in auto setup (auto.go) and stop loading/using the ticket limit in the user stats command (statsuser.go) — the "Open Tickets" field now shows only the open count. This cleans up the code paths for the deprecated ticket limit feature. --- bot/command/impl/settings/setup/auto.go | 6 +-- bot/command/impl/settings/setup/limit.go | 47 ------------------------ bot/command/impl/settings/setup/setup.go | 1 - bot/command/impl/statistics/statsuser.go | 12 +----- event/caller.go | 15 -------- 5 files changed, 2 insertions(+), 79 deletions(-) delete mode 100644 bot/command/impl/settings/setup/limit.go diff --git a/bot/command/impl/settings/setup/auto.go b/bot/command/impl/settings/setup/auto.go index f577e781..9c687669 100644 --- a/bot/command/impl/settings/setup/auto.go +++ b/bot/command/impl/settings/setup/auto.go @@ -111,13 +111,9 @@ func (AutoSetupCommand) Execute(ctx registry.CommandContext) { Type: channel.ChannelTypeGuildCategory, } - switch category, err := ctx.Worker().CreateGuildChannel(context.Background(), ctx.GuildId(), categoryData); err { + switch _, err := ctx.Worker().CreateGuildChannel(context.Background(), ctx.GuildId(), categoryData); err { case nil: // ok 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 messageContent += fmt.Sprintf("\n❌ %s", i18n.GetMessageFromGuild(ctx.GuildId(), i18n.SetupAutoCategoryFailure)) } 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 index 5775ec23..fcceeba9 100644 --- a/bot/command/impl/settings/setup/setup.go +++ b/bot/command/impl/settings/setup/setup.go @@ -20,7 +20,6 @@ func (SetupCommand) Properties() registry.Properties { Category: command.Settings, Children: []registry.Command{ AutoSetupCommand{}, - LimitSetupCommand{}, }, } } diff --git a/bot/command/impl/statistics/statsuser.go b/bot/command/impl/statistics/statsuser.go index c6689a4a..8af0c582 100644 --- a/bot/command/impl/statistics/statsuser.go +++ b/bot/command/impl/statistics/statsuser.go @@ -68,7 +68,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 +100,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 @@ -125,7 +115,7 @@ func (StatsUserCommand) Execute(ctx registry.CommandContext, userId uint64) { 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) + AddField("Open Tickets", strconv.Itoa(openTickets), true) _, _ = ctx.ReplyWith(command.NewEphemeralEmbedMessageResponse(msgEmbed)) span.Finish() diff --git a/event/caller.go b/event/caller.go index 3f19cbf5..f6cbabf2 100644 --- a/event/caller.go +++ b/event/caller.go @@ -367,21 +367,6 @@ func callCommand( 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: v.Execute(ctx) From ef687b9e12c676450c690056b6f9aa5333fbdf35 Mon Sep 17 00:00:00 2001 From: biast12 Date: Thu, 23 Apr 2026 21:28:08 +0200 Subject: [PATCH 04/35] Refactor setup command into settings Move and consolidate the setup command implementation into the settings package: rename bot/command/impl/settings/auto.go -> bot/command/impl/settings/setup.go (package changed to settings) and delete the old parent setup file. Replace AutoSetupCommand with a single SetupCommand, remove the Children property/parent command behavior, and remove the freePanelLimit constant. Update command registration and event caller imports/usages to reference settings.SetupCommand instead of settings/setup. Also apply small cleanup changes in Execute (ignore edit error), getColour, and minor formatting tweaks. --- .../impl/settings/{setup/auto.go => setup.go} | 29 +++---- bot/command/impl/settings/setup/setup.go | 79 ------------------- bot/command/manager/manager.go | 3 +- event/caller.go | 6 +- 4 files changed, 12 insertions(+), 105 deletions(-) rename bot/command/impl/settings/{setup/auto.go => setup.go} (91%) delete mode 100644 bot/command/impl/settings/setup/setup.go diff --git a/bot/command/impl/settings/setup/auto.go b/bot/command/impl/settings/setup.go similarity index 91% rename from bot/command/impl/settings/setup/auto.go rename to bot/command/impl/settings/setup.go index 9c687669..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 @@ -112,22 +108,18 @@ func (AutoSetupCommand) Execute(ctx registry.CommandContext) { } switch _, err := ctx.Worker().CreateGuildChannel(context.Background(), ctx.GuildId(), categoryData); err { - case nil: // ok + case nil: messageContent += fmt.Sprintf("\n✅ %s", i18n.GetMessageFromGuild(ctx.GuildId(), i18n.SetupAutoCategorySuccess)) - 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 } } @@ -148,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 } @@ -163,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/setup.go b/bot/command/impl/settings/setup/setup.go deleted file mode 100644 index fcceeba9..00000000 --- a/bot/command/impl/settings/setup/setup.go +++ /dev/null @@ -1,79 +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{}, - }, - } -} - -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/manager/manager.go b/bot/command/manager/manager.go index f5f4e02c..f11423ca 100644 --- a/bot/command/manager/manager.go +++ b/bot/command/manager/manager.go @@ -6,7 +6,6 @@ import ( "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/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,7 +44,7 @@ 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{} diff --git a/event/caller.go b/event/caller.go index f6cbabf2..3bd0312c 100644 --- a/event/caller.go +++ b/event/caller.go @@ -14,7 +14,6 @@ import ( "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/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" @@ -364,10 +363,7 @@ func callCommand( case settings.ViewStaffCommand: v.Execute(ctx) - case setup.AutoSetupCommand: - - v.Execute(ctx) - case setup.SetupCommand: + case settings.SetupCommand: v.Execute(ctx) case statistics.StatsCommand: From 4e98c65d244a914a91c27f24d0b193827dea7085 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 26 Apr 2026 18:19:01 +0100 Subject: [PATCH 05/35] analytics & central bot staff Signed-off-by: Ben --- .../handlers/admindebug/server/recache.go | 2 +- bot/button/manager/handler.go | 2 +- bot/command/impl/general/help.go | 2 +- bot/command/impl/statistics/statsserver.go | 45 +++++++++------- bot/command/impl/tickets/close.go | 4 +- bot/dbclient/analytics.go | 38 ------------- bot/logic/welcomemessage.go | 16 +++--- bot/utils/adminutils.go | 36 ++++++++----- bot/utils/retriever.go | 4 +- cmd/worker/main.go | 4 -- config/config.go | 11 +--- event/commandexecutor.go | 4 +- go.mod | 8 --- go.sum | 54 ------------------- 14 files changed, 68 insertions(+), 162 deletions(-) delete mode 100644 bot/dbclient/analytics.go 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/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/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/statistics/statsserver.go b/bot/command/impl/statistics/statsserver.go index a6f38a1b..d4ee12cd 100644 --- a/bot/command/impl/statistics/statsserver.go +++ b/bot/command/impl/statistics/statsserver.go @@ -6,8 +6,8 @@ import ( "strings" "time" - "github.com/TicketsBot-cloud/analytics-client" "github.com/TicketsBot-cloud/common/permission" + "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/channel/embed" "github.com/TicketsBot-cloud/gdl/objects/interaction" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" @@ -54,12 +54,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 +83,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 } 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/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/logic/welcomemessage.go b/bot/logic/welcomemessage.go index b7f0ab65..aded063a 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -505,8 +505,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) @@ -529,11 +529,11 @@ var substitutions = map[string]PlaceholderSubstitutionFunc{ 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 { @@ -561,7 +561,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 "" @@ -582,7 +582,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 "" @@ -606,7 +606,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 "" 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/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 dd43762a..bf198f95 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -106,10 +106,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)) diff --git a/config/config.go b/config/config.go index db290adb..47ffac40 100644 --- a/config/config.go +++ b/config/config.go @@ -39,8 +39,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"` } @@ -73,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"` 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/go.mod b/go.mod index 7001efa0..64ee3df1 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ replace github.com/TicketsBot-cloud/database => ../database 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-20260412182419-83b9a6ea08e7 github.com/TicketsBot-cloud/database v0.0.0-20260423165031-495c2e8a5bc7 @@ -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 @@ -111,7 +105,6 @@ 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 @@ -122,7 +115,6 @@ 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 diff --git a/go.sum b/go.sum index c7085405..5bc22a9a 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,10 @@ 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-20260412182419-83b9a6ea08e7 h1:dFmPLk9KXGRVSfiuKK6kusVhRKG7nGfiGld+WRuiX7w= github.com/TicketsBot-cloud/common v0.0.0-20260412182419-83b9a6ea08e7/go.mod h1:jXGcmAuRvv92YqITskvClgoCpFVqYw14CKJdYhiLtVU= -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/gdl v0.0.0-20260306134952-cccb0116fef6 h1:ucG0xLPt7xixW7/LvL0hXDBDouDRS1Nf+77qP8iJ/X0= github.com/TicketsBot-cloud/gdl v0.0.0-20260306134952-cccb0116fef6/go.mod h1:CdwBR2egPtxUXjD2CgC9ZwfuB8dz9HPePM8nuG6dt7Y= github.com/TicketsBot-cloud/logarchiver v0.0.0-20251018211319-7a7df5cacbdc h1:qTLNpCvIqM7UwZ6MdWQ9EztcDsIJfHh+VJdG+ULLEaA= @@ -45,8 +37,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 +92,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 +124,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= @@ -234,9 +215,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 +264,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,9 +276,6 @@ 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= @@ -342,8 +317,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 +336,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,7 +346,6 @@ 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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= @@ -385,19 +356,10 @@ github.com/twmb/franz-go/pkg/kmsg v1.11.2 h1:hIw75FpwcAjgeyfIGFqivAvwC5uNIOWRGvQ github.com/twmb/franz-go/pkg/kmsg v1.11.2/go.mod h1:CFfkkLysDNmukPYhGzuUcDtf46gQSqCZHMW1T4Z+wDE= 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= @@ -448,7 +410,6 @@ 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= @@ -458,8 +419,6 @@ golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aC 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= @@ -468,10 +427,7 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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= @@ -481,9 +437,6 @@ golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= 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= @@ -498,9 +451,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= @@ -541,8 +492,6 @@ 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= @@ -551,7 +500,6 @@ golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8T 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= @@ -563,8 +511,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= From 3f900711805659bd152e97d7986bd14299ebfc2a Mon Sep 17 00:00:00 2001 From: biast12 Date: Mon, 4 May 2026 08:57:29 +0200 Subject: [PATCH 06/35] Add .env.example with configuration template Introduce a new .env.example providing a comprehensive environment configuration template for the project. It documents sections and variables for Worker, Discord, Bot, Database, Cache, Redis, Kafka, proxies, Archiver, Web proxy, Integrations, Experiments, Observability and emoji IDs, marking required fields and listing default values where applicable to help developers set up local or production environments. --- .env.example | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .env.example 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= From 593889b71ae46f7835c42d65e15a17b383eec438 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sun, 10 May 2026 15:43:25 +0200 Subject: [PATCH 07/35] Use per-panel ticket settings Switch many ticket flows to respect per-panel configuration instead of global settings. Loads Panel records where applicable and use panel flags (StoreTranscripts, FeedbackEnabled, CloseConfirmation, SupportCanView, SupportCanType, UsersCanClose, OverflowEnabled/CategoryId, ThreadArchiveDuration, panel auto-close settings) to drive behavior. Key changes: - Button handlers (close, open survey, exit survey, edit close reason, rate) now consult panel settings and pass booleans to logic. - Logic updates: CloseTicket/OpenTicket/claim/sendCloseEmbed now use panel data (including overflow/category logic and thread archive duration). - EditGuildArchiveMessageIfExists and EditDMMessageIfExists signatures changed to accept storeTranscripts and feedbackEnabled instead of full settings. - Member leave auto-close now iterates open tickets and applies per-panel auto-close + exclusion checks. - Permission/claim utilities now derive SupportCanView/Type and UsersCanClose from panel settings and panel-specific ticket permissions. - Removed several now-unnecessary global settings loads (e.g. debug command) and updated callers. - Updated locale submodule reference. This makes ticket behavior configurable on a per-panel basis and centralizes panel-driven overrides across the codebase. --- bot/button/handlers/close.go | 14 ++-- bot/button/handlers/editclosereasonsubmit.go | 19 ++++-- bot/button/handlers/exitsurveysubmit.go | 31 +++++---- bot/button/handlers/opensurvey.go | 16 ++--- bot/button/handlers/rate.go | 35 +++++----- bot/command/impl/admin/debug/debugserver.go | 7 -- bot/listeners/memberleave.go | 67 ++++++++++--------- .../messagequeue/closereasonupdate.go | 19 ++++-- bot/logic/claim.go | 36 +++++----- bot/logic/close.go | 59 ++++++---------- bot/logic/closeembed.go | 14 ++-- bot/logic/open.go | 50 ++++++++------ bot/utils/permissionutils.go | 13 +++- 13 files changed, 194 insertions(+), 186 deletions(-) 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/opensurvey.go b/bot/button/handlers/opensurvey.go index 33e1ed8c..df1dce7f 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 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/command/impl/admin/debug/debugserver.go b/bot/command/impl/admin/debug/debugserver.go index 6eecce11..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) @@ -282,7 +276,6 @@ func (AdminDebugServerCommand) Execute(ctx registry.CommandContext, raw string) 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("Thread Mode Panels: `%d/%d`", threadPanelCount, panelCount), fmt.Sprintf("Bot Has Administrator: `%t`", hasAdministrator), 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/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/logic/claim.go b/bot/logic/claim.go index 70de2d8f..968944dd 100644 --- a/bot/logic/claim.go +++ b/bot/logic/claim.go @@ -103,13 +103,27 @@ func ClaimTicket(ctx context.Context, cmd registry.CommandContext, ticket databa // GenerateClaimedOverwrites If support reps can still view and type, returns (nil, nil) 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 } @@ -123,26 +137,18 @@ func GenerateClaimedOverwrites(ctx context.Context, worker *worker.Context, tick return nil, err } - var additionalPermissions database.TicketPermissions - if ticket.PanelId != nil { - additionalPermissions, err = dbclient.Client.PanelTicketPermissions.Get(ctx, *ticket.PanelId) - if err != nil { - return nil, err - } - } - integrationRoleId, err := GetIntegrationRoleId(ctx, worker, ticket.GuildId) if err != nil { return nil, err } // Support can't view the ticket, and therefore can't type either - if !claimSettings.SupportCanView { + if !supportCanView { return overwritesCantView(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 26743865..3ab05c1e 100644 --- a/bot/logic/close.go +++ b/bot/logic/close.go @@ -60,10 +60,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 +93,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 @@ -274,19 +282,7 @@ 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 { notificationChannel = panel.TicketNotificationChannel } @@ -299,22 +295,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) { +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 + // Send logs to archive channel (per-panel transcript channel only) var archiveChannelId *uint64 - - if ticket.PanelId != nil { - p, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) - if err != nil { - sentry.ErrorWithContext(err, errorContext) - return - } - if p.PanelId != 0 { - archiveChannelId = p.TranscriptChannelId - } + if panel != nil { + archiveChannelId = panel.TranscriptChannelId } var archiveChannelExists bool @@ -327,7 +318,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(), }, @@ -362,12 +353,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 { @@ -400,7 +385,7 @@ func sendCloseEmbed(ctx context.Context, cmd registry.CommandContext, errorConte componentBuilders := [][]CloseEmbedElement{ { - TranscriptLinkElement(settings.StoreTranscripts), + TranscriptLinkElement(storeTranscripts), ThreadLinkElement(ticket.IsThread && ticket.ChannelId != nil), }, { 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 35689aad..f0ca89df 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -156,14 +156,6 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat return database.Ticket{}, nil } - span = sentry.StartSpan(rootSpan.Context(), "Load settings") - settings, err := cmd.Settings() - if err != nil { - cmd.HandleError(err) - return database.Ticket{}, err - } - span.Finish() - // Determine if we should use threads; panel-less tickets always use channel mode isThread := panel != nil && panel.UseThreads @@ -228,7 +220,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) @@ -284,7 +284,11 @@ 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 + if panel != nil { + threadArchiveDuration = panel.ThreadArchiveDuration + } + ch, err = cmd.Worker().CreatePrivateThread(reasonCtx, cmd.ChannelId(), name, uint16(threadArchiveDuration), false) if err != nil { cmd.HandleError(err) @@ -639,7 +643,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") @@ -660,7 +666,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 } @@ -676,9 +682,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) @@ -691,7 +697,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 { @@ -701,7 +707,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 } } @@ -714,19 +720,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 } @@ -734,7 +740,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 } 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 From 3d69648e5e3c1b419f69413f4e07ed416ecfdef4 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Thu, 14 May 2026 21:44:45 +0200 Subject: [PATCH 08/35] Use fixed thread archive duration Remove the conditional that set threadArchiveDuration from panel.ThreadArchiveDuration and always use the default value (10080) when creating a private thread. This simplifies the OpenTicket flow in bot/logic/open.go by not relying on the panel-provided archive duration. --- bot/logic/open.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/logic/open.go b/bot/logic/open.go index f0ca89df..57d868c5 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -285,9 +285,6 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat span = sentry.StartSpan(rootSpan.Context(), "Create thread") reasonCtx := request.WithAuditReason(context.Background(), auditReason) threadArchiveDuration := 10080 - if panel != nil { - threadArchiveDuration = panel.ThreadArchiveDuration - } ch, err = cmd.Worker().CreatePrivateThread(reasonCtx, cmd.ChannelId(), name, uint16(threadArchiveDuration), false) if err != nil { cmd.HandleError(err) From 3d7b9599ba72cbbabb49e65655451399b9265c14 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Fri, 15 May 2026 16:26:11 +0200 Subject: [PATCH 09/35] Use MentionBehaviour instead of DeleteMentions Replace the boolean DeleteMentions usage with the MentionBehaviour string. Update the panel settings modal to display the Mention Behaviour value and change the ticket open logic to delete ping messages only when MentionBehaviour == "delete". This aligns UI text and runtime checks with the new mention behaviour representation. --- .../handlers/admindebug/server/modals/panelsettings.go | 7 +------ bot/logic/open.go | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) 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/logic/open.go b/bot/logic/open.go index 57d868c5..9277b6f5 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -558,7 +558,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() From 296703651faf4b286c04ecaf954afe83e8ef2ab8 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Fri, 15 May 2026 18:07:25 +0200 Subject: [PATCH 10/35] Remove stray TODO comment in OpenTicket Remove a leftover "// TODO: Remove" comment in bot/logic/open.go inside the OpenTicket function. This is a minor cleanup with no functional changes. --- bot/logic/open.go | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/logic/open.go b/bot/logic/open.go index 9277b6f5..d9c01c07 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -376,7 +376,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") From 792ccdef1411b71e7be16166e71f82d6d6a952fa Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 19 May 2026 20:10:25 +0100 Subject: [PATCH 11/35] stats & form api Signed-off-by: Ben --- bot/button/handlers/form.go | 2 +- bot/button/handlers/formapiconfig.go | 119 +++++++++++++++++ bot/button/handlers/multipanel.go | 7 +- bot/button/handlers/opensurvey.go | 2 + bot/button/handlers/panel.go | 21 ++- bot/command/impl/statistics/statsserver.go | 146 +++++++++++++++++++++ bot/command/impl/statistics/statsuser.go | 13 +- bot/command/impl/tickets/open.go | 6 +- bot/command/impl/tickets/startticket.go | 2 +- bot/integrations/customintegrations.go | 6 +- bot/logic/close.go | 59 +++++++++ bot/logic/open.go | 8 +- bot/metrics/prometheus/productmetrics.go | 64 +++++++++ bot/metrics/prometheus/prometheus.go | 15 +++ bot/redis/panelcooldown.go | 2 +- cmd/worker/main.go | 1 + event/httplisten.go | 2 +- 17 files changed, 455 insertions(+), 20 deletions(-) create mode 100644 bot/button/handlers/formapiconfig.go create mode 100644 bot/metrics/prometheus/productmetrics.go 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..df06de72 --- /dev/null +++ b/bot/button/handlers/formapiconfig.go @@ -0,0 +1,119 @@ +package handlers + +import ( + "context" + "encoding/json" + "strconv" + "strings" + + "github.com/TicketsBot-cloud/common/sentry" + "github.com/TicketsBot-cloud/database" + "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 FetchApiOptions(ctx context.Context, formId int, userId uint64, inputs []database.FormInput, inputOptions map[int][]database.FormInputOption) { + configs, err := dbclient.Client.FormInputApiConfig.GetByFormId(ctx, formId) + 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 + } + + for _, input := range inputs { + cfg, ok := configByInputId[input.Id] + if !ok { + continue + } + + options, err := fetchOptionsFromApi(ctx, cfg, userId) + if err != nil { + sentry.Error(err) + options = fallbackOptions(cfg) + } + + if len(options) == 0 { + options = fallbackOptions(cfg) + } + + inputOptions[input.Id] = options + } +} + +func fetchOptionsFromApi(ctx context.Context, cfg database.FormInputApiConfig, userId uint64) ([]database.FormInputOption, error) { + url := substituteplaceholders(cfg.EndpointUrl, userId) + + 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] = substituteplaceholders(h.HeaderValue, userId) + } + + res, err := integrations.SecureProxy.DoRequest(ctx, cfg.Method, url, headerMap, nil) + 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, + }) + } + + return options, nil +} + +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", + }, + } +} + +func substituteplaceholders(s string, userId uint64) string { + return strings.ReplaceAll(s, "%user_id%", strconv.FormatUint(userId, 10)) +} diff --git a/bot/button/handlers/multipanel.go b/bot/button/handlers/multipanel.go index da04b91a..bdd75943 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" @@ -58,7 +59,7 @@ func (h *MultiPanelHandler) Execute(ctx *context.SelectMenuContext) { } 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 +84,10 @@ func (h *MultiPanelHandler) Execute(ctx *context.SelectMenuContext) { return } + FetchApiOptions(ctx, form.Id, ctx.UserId(), 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 df1dce7f..f78c275b 100644 --- a/bot/button/handlers/opensurvey.go +++ b/bot/button/handlers/opensurvey.go @@ -126,6 +126,8 @@ func (h *OpenSurveyHandler) Execute(ctx *context.ButtonContext) { return } + FetchApiOptions(ctx, form.Id, ctx.UserId(), 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 405e70fc..ff519a39 100644 --- a/bot/button/handlers/panel.go +++ b/bot/button/handlers/panel.go @@ -56,7 +56,7 @@ func (h *PanelHandler) Execute(ctx *context.ButtonContext) { } 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 { @@ -81,8 +81,10 @@ func (h *PanelHandler) Execute(ctx *context.ButtonContext) { return } + FetchApiOptions(ctx, form.Id, ctx.UserId(), 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) @@ -126,11 +128,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 diff --git a/bot/command/impl/statistics/statsserver.go b/bot/command/impl/statistics/statsserver.go index d4ee12cd..e51402b5 100644 --- a/bot/command/impl/statistics/statsserver.go +++ b/bot/command/impl/statistics/statsserver.go @@ -148,6 +148,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 @@ -209,6 +263,48 @@ func (StatsServerCommand) Execute(ctx registry.CommandContext) { } } + 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 + } + + 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{ @@ -219,6 +315,22 @@ func (StatsServerCommand) Execute(ctx registry.CommandContext) { 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```", @@ -231,6 +343,25 @@ func (StatsServerCommand) Execute(ctx registry.CommandContext) { Components: innerComponents, })))) } else { + 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 + } + + 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 + } + + var legacyOneTouchPct float64 + if oneTouchResolution.TotalClosed > 0 { + legacyOneTouchPct = float64(oneTouchResolution.OneTouchCount) / float64(oneTouchResolution.TotalClosed) * 100 + } + msgEmbed := embed.NewEmbed(). SetTitle("Statistics"). SetColor(ctx.GetColour(customisation.Green)). @@ -240,12 +371,20 @@ func (StatsServerCommand) Execute(ctx registry.CommandContext) { AddField("Feedback Rating", fmt.Sprintf("%.1f / 5 ⭐", feedbackRating), true). AddField("Feedback Count", strconv.FormatUint(feedbackCount, 10), true). AddBlankField(true). + AddField("Feedback Distribution", fmt.Sprintf("★1:%d ★2:%d ★3:%d ★4:%d ★5:%d", feedbackDist[0], feedbackDist[1], feedbackDist[2], feedbackDist[3], feedbackDist[4]), false). + AddField("Feedback Rate", fmt.Sprintf("%.0f%% (%d/%d tickets)", feedbackRate.Rate*100, feedbackRate.RatedTickets, feedbackRate.ClosedTickets), 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("One-Touch Resolution", fmt.Sprintf("%.0f%% (%d/%d)", legacyOneTouchPct, oneTouchResolution.OneTouchCount, oneTouchResolution.TotalClosed), true). + AddField("Avg Messages/Ticket", fmt.Sprintf("Staff: %s | User: %s", formatNullableFloat(avgMessageCounts.AvgStaffMessages), formatNullableFloat(avgMessageCounts.AvgUserMessages)), true). + AddBlankField(true). + AddField("Auto-close / Manual", fmt.Sprintf("Auto: %d (%.0f%%) | Manual: %d (%.0f%%)", autoCloseStats.AutoClosed, autoClosePct, autoCloseStats.ManualClosed, manualClosePct), false). + AddField("Thread / Channel", fmt.Sprintf("Thread: %d (%.0f%%) | Channel: %d (%.0f%%)", threadSplit.ThreadCount, threadPct, threadSplit.ChannelCount, channelPct), false). AddField("Ticket Volume", fmt.Sprintf("```\n%s\n```", ticketVolumeTable), false) _, _ = ctx.ReplyWith(command.NewEphemeralEmbedMessageResponse(msgEmbed)) @@ -257,3 +396,10 @@ func (StatsServerCommand) Execute(ctx registry.CommandContext) { 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 8af0c582..784d5171 100644 --- a/bot/command/impl/statistics/statsuser.go +++ b/bot/command/impl/statistics/statsuser.go @@ -254,6 +254,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 @@ -298,6 +307,7 @@ func (StatsUserCommand) Execute(ctx registry.CommandContext, userId uint64) { fmt.Sprintf("**Total**: %d", totalClaimedTickets), fmt.Sprintf("**Monthly**: %d", monthlyClaimedTickets), fmt.Sprintf("**Weekly**: %d", weeklyClaimedTickets), + fmt.Sprintf("**Currently Open**: %d", openClaimedCount), } var topSection []component.Component @@ -368,7 +378,8 @@ func (StatsUserCommand) Execute(ctx registry.CommandContext, userId uint64) { 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) + AddField("Claimed Tickets (Total)", strconv.Itoa(totalClaimedTickets), true). + AddField("Open Tickets (Claimed)", strconv.Itoa(openClaimedCount), true) _, _ = ctx.ReplyWith(command.NewEphemeralEmbedMessageResponse(msgEmbed)) } diff --git a/bot/command/impl/tickets/open.go b/bot/command/impl/tickets/open.go index 20a0c512..ef92dbba 100644 --- a/bot/command/impl/tickets/open.go +++ b/bot/command/impl/tickets/open.go @@ -118,7 +118,7 @@ func openWithPanel(ctx *cmdcontext.SlashCommandContext, panel database.Panel) { } 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.TicketSourceCommand) return } @@ -145,8 +145,10 @@ func openWithPanel(ctx *cmdcontext.SlashCommandContext, panel database.Panel) { return } + handlers.FetchApiOptions(ctx, form.Id, ctx.UserId(), inputs, inputOptions) + if len(inputs) == 0 { - 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.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 b7e2c17f..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 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/logic/close.go b/bot/logic/close.go index 3ab05c1e..79020771 100644 --- a/bot/logic/close.go +++ b/bot/logic/close.go @@ -10,6 +10,7 @@ import ( "github.com/TicketsBot-cloud/common/collections" "github.com/TicketsBot-cloud/common/permission" "github.com/TicketsBot-cloud/common/sentry" + botcache "github.com/TicketsBot-cloud/worker/bot/cache" "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/channel/embed" "github.com/TicketsBot-cloud/gdl/objects/channel/message" @@ -157,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 @@ -480,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/open.go b/bot/logic/open.go index d9c01c07..c1b42393 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -36,7 +36,7 @@ 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) { rootSpan := sentry.StartSpan(ctx, "Ticket open") rootSpan.SetTag("guild", strconv.FormatUint(cmd.GuildId(), 10)) defer rootSpan.Finish() @@ -251,7 +251,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 @@ -944,7 +944,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 { @@ -982,7 +982,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 { 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 11628667..23ff2cfa 100644 --- a/bot/metrics/prometheus/prometheus.go +++ b/bot/metrics/prometheus/prometheus.go @@ -39,6 +39,13 @@ var ( KafkaMessages = newHistogramVec("kafka_messages", "topic") 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/cmd/worker/main.go b/cmd/worker/main.go index bf198f95..751ac405 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -155,6 +155,7 @@ func main() { go messagequeue.ListenCloseReasonUpdate() go blacklist.StartCacheRefreshLoop(logger.With(zap.String("service", "blacklist_refresh"))) + go prometheus.StartProductMetricsLoop(logger.With(zap.String("service", "product_metrics"))) if config.Conf.WorkerMode == config.WorkerModeInteractions { logger.Info("Starting HTTP server", zap.String("mode", string(config.Conf.WorkerMode))) diff --git a/event/httplisten.go b/event/httplisten.go index 1db5de7f..feeaffda 100644 --- a/event/httplisten.go +++ b/event/httplisten.go @@ -341,7 +341,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)) } } From 8f38aee0735c838c9e92730e1ad48c128b4d313c Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 26 May 2026 20:01:29 +0100 Subject: [PATCH 12/35] cleaned up stats cmds Signed-off-by: Ben --- bot/command/impl/statistics/statsserver.go | 283 +++++++++------------ bot/command/impl/statistics/statsuser.go | 223 ++++++++-------- experiments/experiments.go | 8 +- 3 files changed, 231 insertions(+), 283 deletions(-) diff --git a/bot/command/impl/statistics/statsserver.go b/bot/command/impl/statistics/statsserver.go index e51402b5..f1ba1629 100644 --- a/bot/command/impl/statistics/statsserver.go +++ b/bot/command/impl/statistics/statsserver.go @@ -8,15 +8,12 @@ import ( "github.com/TicketsBot-cloud/common/permission" "github.com/TicketsBot-cloud/database" - "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" "github.com/jedib0t/go-pretty/v6/table" @@ -209,187 +206,139 @@ 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, }, }), - } - } - - 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 - } - - 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)), + Components: []component.Component{ + component.BuildTextDisplay(component.TextDisplay{Content: "## Server Ticket Statistics"}), + component.BuildTextDisplay(component.TextDisplay{ + Content: fmt.Sprintf("● %s", strings.Join(mainStats, "\n● ")), + }), + }, + }), } + } - closureStats := []string{ - fmt.Sprintf("**Auto-closed**: %d (%.0f%%)", autoCloseStats.AutoClosed, autoClosePct), - fmt.Sprintf("**Manual**: %d (%.0f%%)", autoCloseStats.ManualClosed, manualClosePct), - } + 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 + } - threadStats := []string{ - fmt.Sprintf("**Thread**: %d (%.0f%%)", threadSplit.ThreadCount, threadPct), - fmt.Sprintf("**Channel**: %d (%.0f%%)", threadSplit.ChannelCount, channelPct), - } + 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 + } - 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, - ), - }), - }...) + 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), + } - ctx.ReplyWith(command.NewEphemeralMessageResponseWithComponents(utils.Slice(component.BuildContainer(component.Container{ - Components: innerComponents, - })))) - } else { - 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 - } + var oneTouchPct float64 + if oneTouchResolution.TotalClosed > 0 { + oneTouchPct = float64(oneTouchResolution.OneTouchCount) / float64(oneTouchResolution.TotalClosed) * 100 + } - 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 - } + 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)), + } - var legacyOneTouchPct float64 - if oneTouchResolution.TotalClosed > 0 { - legacyOneTouchPct = float64(oneTouchResolution.OneTouchCount) / float64(oneTouchResolution.TotalClosed) * 100 - } + closureStats := []string{ + fmt.Sprintf("**Auto-closed**: %d (%.0f%%)", autoCloseStats.AutoClosed, autoClosePct), + fmt.Sprintf("**Manual**: %d (%.0f%%)", autoCloseStats.ManualClosed, manualClosePct), + } - 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("Feedback Distribution", fmt.Sprintf("★1:%d ★2:%d ★3:%d ★4:%d ★5:%d", feedbackDist[0], feedbackDist[1], feedbackDist[2], feedbackDist[3], feedbackDist[4]), false). - AddField("Feedback Rate", fmt.Sprintf("%.0f%% (%d/%d tickets)", feedbackRate.Rate*100, feedbackRate.RatedTickets, feedbackRate.ClosedTickets), 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("One-Touch Resolution", fmt.Sprintf("%.0f%% (%d/%d)", legacyOneTouchPct, oneTouchResolution.OneTouchCount, oneTouchResolution.TotalClosed), true). - AddField("Avg Messages/Ticket", fmt.Sprintf("Staff: %s | User: %s", formatNullableFloat(avgMessageCounts.AvgStaffMessages), formatNullableFloat(avgMessageCounts.AvgUserMessages)), true). - AddBlankField(true). - AddField("Auto-close / Manual", fmt.Sprintf("Auto: %d (%.0f%%) | Manual: %d (%.0f%%)", autoCloseStats.AutoClosed, autoClosePct, autoCloseStats.ManualClosed, manualClosePct), false). - AddField("Thread / Channel", fmt.Sprintf("Thread: %d (%.0f%%) | Channel: %d (%.0f%%)", threadSplit.ThreadCount, threadPct, threadSplit.ChannelCount, channelPct), false). - AddField("Ticket Volume", fmt.Sprintf("```\n%s\n```", ticketVolumeTable), false) - - _, _ = ctx.ReplyWith(command.NewEphemeralEmbedMessageResponse(msgEmbed)) + 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() } diff --git a/bot/command/impl/statistics/statsuser.go b/bot/command/impl/statistics/statsuser.go index 784d5171..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" @@ -107,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", strconv.Itoa(openTickets), 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) @@ -277,113 +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), - fmt.Sprintf("**Currently Open**: %d", openClaimedCount), - } + 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{}), - component.BuildTextDisplay(component.TextDisplay{ - Content: fmt.Sprintf( - "### Tickets Answered\n● %s", - strings.Join(ticketsAnsweredStats, "\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( - "### Claimed Tickets\n● %s", - strings.Join(claimedStats, "\n● "), - ), + Content: fmt.Sprintf("● %s", strings.Join(mainStats, "\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). - AddField("Open Tickets (Claimed)", strconv.Itoa(openClaimedCount), 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/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" { From a9c22ad0bef7efd0f94247f6b872aaa5c36c685b Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Thu, 28 May 2026 23:09:56 +0200 Subject: [PATCH 13/35] Cache form API options; publish category updates Add in-memory caching for FormInput API options to reduce repeated external requests. A mutex-protected map stores options per API config and user with TTL based on CacheDurationSeconds; cache is checked before fetching and populated after a successful fetch. Also add a clone helper to avoid sharing slices. Introduce a new category update publisher (messagequeue/categoryupdate.go) that polls the CategoryUpdateQueue and produces RPC topic messages to update ticket channel categories (with configurable delay/interval). Wire the publisher into the worker startup in cmd/worker/main.go. --- bot/button/handlers/formapiconfig.go | 45 +++++++++++ bot/listeners/messagequeue/categoryupdate.go | 81 ++++++++++++++++++++ cmd/worker/main.go | 2 + 3 files changed, 128 insertions(+) create mode 100644 bot/listeners/messagequeue/categoryupdate.go diff --git a/bot/button/handlers/formapiconfig.go b/bot/button/handlers/formapiconfig.go index df06de72..74e46187 100644 --- a/bot/button/handlers/formapiconfig.go +++ b/bot/button/handlers/formapiconfig.go @@ -3,8 +3,11 @@ package handlers import ( "context" "encoding/json" + "fmt" "strconv" "strings" + "sync" + "time" "github.com/TicketsBot-cloud/common/sentry" "github.com/TicketsBot-cloud/database" @@ -18,6 +21,18 @@ type apiOption struct { Description *string `json:"description,omitempty"` } +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(ctx context.Context, formId int, userId uint64, inputs []database.FormInput, inputOptions map[int][]database.FormInputOption) { configs, err := dbclient.Client.FormInputApiConfig.GetByFormId(ctx, formId) if err != nil { @@ -55,6 +70,21 @@ func FetchApiOptions(ctx context.Context, formId int, userId uint64, inputs []da } func fetchOptionsFromApi(ctx context.Context, cfg database.FormInputApiConfig, userId uint64) ([]database.FormInputOption, error) { + cacheKey := fmt.Sprintf("%d:%d", cfg.Id, userId) + 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() + } + url := substituteplaceholders(cfg.EndpointUrl, userId) headers, err := dbclient.Client.FormInputApiHeaders.GetByApiConfig(ctx, cfg.Id) @@ -95,9 +125,24 @@ func fetchOptionsFromApi(ctx context.Context, cfg database.FormInputApiConfig, u }) } + 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 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 != "" { 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/cmd/worker/main.go b/cmd/worker/main.go index 751ac405..1c3c2adc 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -190,6 +190,8 @@ func main() { return } + go messagequeue.StartCategoryUpdatePublisher(rpcClient, logger.With(zap.String("service", "category-update-publisher"))) + wg.Add(1) go func() { defer wg.Done() From fac2674341da2facad756ca5dd5a3141b26dd4a5 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:12:34 +0200 Subject: [PATCH 14/35] Use permissionwrapper Move the announcement-channel check and remove the Sentry span around it. Replace the previous raw Member permissions check with permissionwrapper.HasPermissionsChannel to verify SendMessagesInThreads for the parent channel; this uses the worker/guild/user/channel context instead of relying on InteractionMetadata.Member.Permissions. Update the reply to include the channel ID when the permission check fails. --- bot/logic/open.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/bot/logic/open.go b/bot/logic/open.go index c1b42393..28953cf1 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -159,8 +159,6 @@ func OpenTicket(ctx context.Context, cmd registry.InteractionContext, panel *dat // Determine if we should use threads; panel-less tickets always use channel mode isThread := panel != nil && panel.UseThreads - // Check if the parent channel is an announcement channel - span = sentry.StartSpan(rootSpan.Context(), "Check if parent channel is announcement channel") if isThread { panelChannel, err := cmd.Channel() if err != nil { @@ -168,18 +166,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 } } From d180e3f6386a982db30963e468d9eae5e847530f Mon Sep 17 00:00:00 2001 From: Ben Hall Date: Fri, 12 Jun 2026 16:19:48 +0100 Subject: [PATCH 15/35] feat: Kafka to Redis (#137) * feat: Kafka to Redis Signed-off-by: Ben * Delete cmd/.DS_Store * Hardcode redis keys --------- Signed-off-by: Ben Co-authored-by: biast12 <53872542+biast12@users.noreply.github.com> --- bot/metrics/prometheus/prometheus.go | 4 ++-- cmd/worker/main.go | 16 +++++++++------- config/config.go | 8 +++----- event/{kafkalisten.go => listener.go} | 12 ++++++------ go.mod | 5 +---- go.sum | 8 -------- locale | 2 +- 7 files changed, 22 insertions(+), 33 deletions(-) rename event/{kafkalisten.go => listener.go} (75%) diff --git a/bot/metrics/prometheus/prometheus.go b/bot/metrics/prometheus/prometheus.go index 23ff2cfa..938a45c1 100644 --- a/bot/metrics/prometheus/prometheus.go +++ b/bot/metrics/prometheus/prometheus.go @@ -35,8 +35,8 @@ var ( ForwardedDashboardMessages = newCounter("forwarded_dashboard_messages") Events = newCounterVec("events", "event_type") - KafkaBatchSize = newHistogram("kafka_batch_size") - KafkaMessages = newHistogramVec("kafka_messages", "topic") + StreamBatchSize = newHistogram("stream_batch_size") + StreamMessages = newHistogramVec("stream_messages", "stream") CategoryUpdates = newCounter("category_updates") diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 1c3c2adc..42be4b65 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -168,21 +168,23 @@ func main() { var wg sync.WaitGroup + hostname, _ := os.Hostname() + rpcClient, err := rpc.NewClient( logger.With(zap.String("service", "rpc")), rpc.Config{ - Brokers: config.Conf.Kafka.Brokers, + Redis: redis.Client, ConsumerGroup: "worker", - ConsumerConcurrency: config.Conf.Kafka.GoroutineLimit, + ConsumerName: hostname, + ConsumerConcurrency: config.Conf.Streams.GoroutineLimit, + MaxLen: 50000, }, map[string]rpc.Listener{ - // Listen for gateway events over Kafka - config.Conf.Kafka.EventsTopic: event.NewKafkaListener( - logger.With(zap.String("service", "gateway-events-kafka")), + "stream:gateway-events": event.NewEventListener( + logger.With(zap.String("service", "gateway-events")), &pgCache, ), - // TODO: Don't hardcode - "tickets.rpc.categoryupdate": listeners.NewTicketStatusUpdater(&pgCache, logger), + "stream:rpc:categoryupdate": listeners.NewTicketStatusUpdater(&pgCache, logger), }) if err != nil { diff --git a/config/config.go b/config/config.go index f0dfc571..dfbf9d6e 100644 --- a/config/config.go +++ b/config/config.go @@ -85,11 +85,9 @@ type ( Threads int `env:"THREADS"` } `envPrefix:"WORKER_REDIS_"` - Kafka struct { - Brokers []string `env:"BROKERS"` - EventsTopic string `env:"EVENTS_TOPIC"` - GoroutineLimit int `env:"GOROUTINE_LIMIT" envDefault:"1000"` - } `envPrefix:"KAFKA_"` + Streams struct { + GoroutineLimit int `env:"STREAMS_GOROUTINE_LIMIT" envDefault:"1000"` + } Prometheus struct { Address string `env:"PROMETHEUS_SERVER_ADDR"` diff --git a/event/kafkalisten.go b/event/listener.go similarity index 75% rename from event/kafkalisten.go rename to event/listener.go index a2639b62..e1a9e1dc 100644 --- a/event/kafkalisten.go +++ b/event/listener.go @@ -10,25 +10,25 @@ import ( "go.uber.org/zap" ) -type KafkaConsumer struct { +type EventListener struct { logger *zap.Logger cache *cache.PgCache } -var _ rpc.Listener = (*KafkaConsumer)(nil) +var _ rpc.Listener = (*EventListener)(nil) -func NewKafkaListener(logger *zap.Logger, cache *cache.PgCache) *KafkaConsumer { - return &KafkaConsumer{ +func NewEventListener(logger *zap.Logger, cache *cache.PgCache) *EventListener { + return &EventListener{ logger: logger, cache: cache, } } -func (k *KafkaConsumer) BuildContext() (context.Context, context.CancelFunc) { +func (k *EventListener) BuildContext() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) } -func (k *KafkaConsumer) HandleMessage(ctx context.Context, message []byte) { +func (k *EventListener) HandleMessage(ctx context.Context, message []byte) { var event eventforwarding.Event if err := json.Unmarshal(message, &event); err != nil { k.logger.Error("Failed to unmarshal event", zap.Error(err)) diff --git a/go.mod b/go.mod index 64ee3df1..257ff475 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.0 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 @@ -107,7 +107,6 @@ require ( github.com/pasztorpisti/qs v0.0.0-20171216220353-8d6c33ee906c // 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 @@ -119,8 +118,6 @@ require ( github.com/tatsuworks/czlib v0.0.0-20190916144400-8a51758ea0d9 // indirect github.com/tinylib/msgp v1.4.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/twmb/franz-go v1.19.0 // indirect - github.com/twmb/franz-go/pkg/kmsg v1.11.2 // indirect github.com/ugorji/go/codec v1.2.12 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect diff --git a/go.sum b/go.sum index 5bc22a9a..d3d572b2 100644 --- a/go.sum +++ b/go.sum @@ -27,8 +27,6 @@ github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0 github.com/ReneKroon/ttlcache v1.6.0/go.mod h1:DG6nbhXKUQhrExfwwLuZUdH7UnRDDRA1IW+nBuCssvs= 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-20260412182419-83b9a6ea08e7 h1:dFmPLk9KXGRVSfiuKK6kusVhRKG7nGfiGld+WRuiX7w= -github.com/TicketsBot-cloud/common v0.0.0-20260412182419-83b9a6ea08e7/go.mod h1:jXGcmAuRvv92YqITskvClgoCpFVqYw14CKJdYhiLtVU= github.com/TicketsBot-cloud/gdl v0.0.0-20260306134952-cccb0116fef6 h1:ucG0xLPt7xixW7/LvL0hXDBDouDRS1Nf+77qP8iJ/X0= github.com/TicketsBot-cloud/gdl v0.0.0-20260306134952-cccb0116fef6/go.mod h1:CdwBR2egPtxUXjD2CgC9ZwfuB8dz9HPePM8nuG6dt7Y= github.com/TicketsBot-cloud/logarchiver v0.0.0-20251018211319-7a7df5cacbdc h1:qTLNpCvIqM7UwZ6MdWQ9EztcDsIJfHh+VJdG+ULLEaA= @@ -280,8 +278,6 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 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= @@ -350,10 +346,6 @@ github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8= github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= 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/twmb/franz-go v1.19.0 h1:FzBAPUeaip68X9cbLDesgQesa5zxKVaZMk+du98vj3c= -github.com/twmb/franz-go v1.19.0/go.mod h1:4kFJ5tmbbl7asgwAGVuyG1ZMx0NNpYk7EqflvWfPCpM= -github.com/twmb/franz-go/pkg/kmsg v1.11.2 h1:hIw75FpwcAjgeyfIGFqivAvwC5uNIOWRGvQgZhH4mhg= -github.com/twmb/franz-go/pkg/kmsg v1.11.2/go.mod h1:CFfkkLysDNmukPYhGzuUcDtf46gQSqCZHMW1T4Z+wDE= 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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/locale b/locale index 6d572d7e..087963f3 160000 --- a/locale +++ b/locale @@ -1 +1 @@ -Subproject commit 6d572d7ee1353b6d0f084a4e8574d0b0d8d7fd40 +Subproject commit 087963f3a474f70d655383103cb044881603e071 From 841df79beec10fa985ec62afe50905df04c6b4d8 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:47:45 +0200 Subject: [PATCH 16/35] Cache public integration owners Add a background refresh loop that fetches public integration owners from Discord and stores them in the user cache so authors resolve even without shared guilds. Wire the loop into the worker startup alongside the existing cache refresh jobs. --- bot/integrationowners/cache.go | 68 ++++++++++++++++++++++++++++++++++ cmd/worker/main.go | 2 + 2 files changed, 70 insertions(+) create mode 100644 bot/integrationowners/cache.go 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/cmd/worker/main.go b/cmd/worker/main.go index 42be4b65..7d42a01c 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -19,6 +19,7 @@ import ( "github.com/TicketsBot-cloud/common/sentry" "github.com/TicketsBot-cloud/gdl/rest/request" "github.com/TicketsBot-cloud/worker/bot/blacklist" + "github.com/TicketsBot-cloud/worker/bot/integrationowners" "github.com/TicketsBot-cloud/worker/bot/cache" "github.com/TicketsBot-cloud/worker/bot/dbclient" "github.com/TicketsBot-cloud/worker/bot/integrations" @@ -155,6 +156,7 @@ func main() { 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"))) if config.Conf.WorkerMode == config.WorkerModeInteractions { From 16cc13ca9b77779e0b096872680034fe3ba510c5 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:29:52 +0200 Subject: [PATCH 17/35] Refactor close request message composition Changed from using separate message templates to dynamically composing the close request message. This allows better control over message structure, ensuring the accept/deny prompt stays at the bottom in Discord. The message now builds in order: intro, optional reason, optional close timestamp, then prompt. --- bot/command/impl/tickets/closerequest.go | 18 +++++++++--------- i18n/messages.go | 5 +++-- 2 files changed, 12 insertions(+), 11 deletions(-) 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/i18n/messages.go b/i18n/messages.go index 1a21ff6b..56c9d099 100644 --- a/i18n/messages.go +++ b/i18n/messages.go @@ -203,8 +203,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" From 1d788955fdf2214154bbcabc1b72c554988441b9 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:35:23 +0200 Subject: [PATCH 18/35] Handle modal source message & response types Detect whether a modal interaction has a source message and route responses accordingly. Added a hasSourceMessage parameter to HandleModalInteraction and passed it from the HTTP listener after probing payload.Event. ModalContext now stores hasSourceMessage; Defer only ACKs when a source message exists, and ReplyWith chooses ResponseMessage vs ResponseEdit based on that flag. Also enabled DisableAutoDefer for the open ticket command to align defer semantics. Imported encoding/json (aliased) to probe the payload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bot/button/manager/modal.go | 4 +-- bot/command/context/modalcontext.go | 44 ++++++++++++++++++----------- bot/command/impl/tickets/open.go | 1 + event/httplisten.go | 15 ++++++++-- 4 files changed, 44 insertions(+), 20 deletions(-) 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/impl/tickets/open.go b/bot/command/impl/tickets/open.go index ef92dbba..b55f0440 100644 --- a/bot/command/impl/tickets/open.go +++ b/bot/command/impl/tickets/open.go @@ -35,6 +35,7 @@ func (OpenCommand) Properties() registry.Properties { command.NewRequiredAutocompleteableArgument("panel", "The panel to open a ticket with", interaction.OptionTypeString, i18n.MessageInvalidArgument, OpenCommand{}.AutoCompleteHandler), ), DefaultEphemeral: true, + DisableAutoDefer: true, Timeout: constants.TimeoutOpenTicket, } } diff --git a/event/httplisten.go b/event/httplisten.go index feeaffda..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) } From 0db8f4e17a891922298af61ae96a59292ebd1942 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:01:54 +0200 Subject: [PATCH 19/35] Add whitelabel admin action buttons Adds admin buttons to resync whitelabel bots and re-create their slash commands from the whitelabel data view. Includes new button handlers, registration in the interaction manager, and a shared command payload builder for recreating commands. --- .../handlers/whitelabel/recreatecommands.go | 110 ++++++++++++++++++ bot/button/handlers/whitelabel/resync.go | 94 +++++++++++++++ bot/button/manager/manager.go | 3 + bot/command/impl/admin/adminwhitelabeldata.go | 26 ++++- 4 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 bot/button/handlers/whitelabel/recreatecommands.go create mode 100644 bot/button/handlers/whitelabel/resync.go 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..55186b22 --- /dev/null +++ b/bot/button/handlers/whitelabel/resync.go @@ -0,0 +1,94 @@ +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 { + 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/manager.go b/bot/button/manager/manager.go index 498c8f54..25678ddd 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" ) @@ -88,6 +89,8 @@ func (m *ComponentInteractionManager) RegisterCommands() { new(server.AdminDebugServerTicketPermissionsHandler), new(server.AdminDebugServerUserTicketsHandler), new(edit.EditLabelsButtonHandler), + new(whitelabelbtn.WhitelabelResyncHandler), + new(whitelabelbtn.WhitelabelRecreateCommandsHandler), ) m.selectRegistry = append(m.selectRegistry, diff --git a/bot/command/impl/admin/adminwhitelabeldata.go b/bot/command/impl/admin/adminwhitelabeldata.go index ea1f3803..ce0288a0 100644 --- a/bot/command/impl/admin/adminwhitelabeldata.go +++ b/bot/command/impl/admin/adminwhitelabeldata.go @@ -141,10 +141,24 @@ func (AdminWhitelabelDataCommand) Execute(ctx registry.CommandContext, userId ui }), } - 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)) } From a1a5ed629ef4f3bf63ef423c94eff872e46bc190 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:00:32 +0200 Subject: [PATCH 20/35] Remove extra header in whitelabel data view Simplify the admin whitelabel data response by removing the hardcoded "## Whitelabel" text display and separator. The command now renders only the generated whitelabel content, avoiding redundant UI elements. --- bot/command/impl/admin/adminwhitelabeldata.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/command/impl/admin/adminwhitelabeldata.go b/bot/command/impl/admin/adminwhitelabeldata.go index ce0288a0..bfeef989 100644 --- a/bot/command/impl/admin/adminwhitelabeldata.go +++ b/bot/command/impl/admin/adminwhitelabeldata.go @@ -134,8 +134,6 @@ 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, }), From a8f26588496bca1e1d5ac0304d4f10edf561ef16 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:03:01 +0200 Subject: [PATCH 21/35] Extract panel availability check into reusable function Refactor duplicate panel disabled/force-disabled checks into a new replyIfPanelUnavailable function. This reduces code duplication and enables the check in ValidatePanelAccess to reject form-backed panels on click rather than after the user fills the modal. --- bot/logic/open.go | 26 ++++++------------------- bot/logic/validation.go | 42 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/bot/logic/open.go b/bot/logic/open.go index 28953cf1..868c988f 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -130,29 +130,15 @@ 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`" - } + panelUnavailable, err := replyIfPanelUnavailable(cmd, panel) + span.Finish() - cmd.Reply(customisation.Red, i18n.Error, i18n.MessageOpenPanelForceDisabled, premiumCommand) - return database.Ticket{}, nil + if err != nil { + sentry.Error(err) + return database.Ticket{}, err } - span.Finish() - - if panel != nil && panel.Disabled { - cmd.Reply(customisation.Red, i18n.Error, i18n.MessageOpenPanelDisabled) + if panelUnavailable { return database.Ticket{}, nil } 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 { From f0e8e11b82dce64f2fc53ee3398eb9bc64aa1dd6 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:24:49 +0200 Subject: [PATCH 22/35] Respect mention behavior in ticket content Only wrap ticket content in spoiler markup when the panel is unset or still allows mentions. This preserves the existing plain-content behavior for panels configured with `mention_behaviour=none`. --- bot/logic/open.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/logic/open.go b/bot/logic/open.go index 868c988f..2a36203c 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -519,7 +519,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] } From c5950924260dfeeb7c65c470a7580a1e1ffdf124 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:47:47 +0200 Subject: [PATCH 23/35] Synchronize mention and welcome message order Add channel-based handoff between goroutines to ensure the welcome message is always sent after mentions/pings in ticket creation. This prevents message reordering when both are sent concurrently from separate goroutines. --- bot/logic/open.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bot/logic/open.go b/bot/logic/open.go index 2a36203c..3bb9eab5 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -403,6 +403,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") @@ -418,6 +423,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() @@ -440,6 +449,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() From b8ca07eb549a3ff4b448c576dd97adc8d5d3790c Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 26 Jul 2026 19:00:12 +0100 Subject: [PATCH 24/35] kb rendering & components v2 support Signed-off-by: Ben --- bot/button/handlers/kb.go | 247 +++++++++++++ bot/button/handlers/kbdeflection.go | 126 +++++++ bot/button/handlers/multipanel.go | 9 + bot/button/handlers/panel.go | 106 +++--- bot/button/manager/manager.go | 3 + bot/command/impl/kb/browse.go | 120 +++++++ bot/command/impl/kb/kb.go | 39 +++ bot/command/impl/kb/render.go | 384 +++++++++++++++++++++ bot/command/impl/kb/render_test.go | 55 +++ bot/command/impl/kb/search.go | 81 +++++ bot/command/impl/kb/send.go | 100 ++++++ bot/command/impl/settings/removesupport.go | 1 - bot/command/manager/manager.go | 3 + bot/command/messageresponse.go | 23 +- bot/command/messageresponse_test.go | 73 ++++ bot/listeners/listeners.go | 1 - bot/logic/claim.go | 2 +- bot/logic/close.go | 2 +- bot/metrics/prometheus/prometheus.go | 2 +- cmd/worker/main.go | 2 +- config/config.go | 2 +- event/caller.go | 68 +++- i18n/messages.go | 21 ++ 23 files changed, 1406 insertions(+), 64 deletions(-) create mode 100644 bot/button/handlers/kb.go create mode 100644 bot/button/handlers/kbdeflection.go create mode 100644 bot/command/impl/kb/browse.go create mode 100644 bot/command/impl/kb/kb.go create mode 100644 bot/command/impl/kb/render.go create mode 100644 bot/command/impl/kb/render_test.go create mode 100644 bot/command/impl/kb/search.go create mode 100644 bot/command/impl/kb/send.go create mode 100644 bot/command/messageresponse_test.go 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 bdd75943..b13ab361 100644 --- a/bot/button/handlers/multipanel.go +++ b/bot/button/handlers/multipanel.go @@ -58,6 +58,15 @@ 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, database.TicketSourcePanel) } else { diff --git a/bot/button/handlers/panel.go b/bot/button/handlers/panel.go index ff519a39..2b8d3530 100644 --- a/bot/button/handlers/panel.go +++ b/bot/button/handlers/panel.go @@ -38,61 +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, database.TicketSourcePanel) - } 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 + } - FetchApiOptions(ctx, form.Id, ctx.UserId(), inputs, inputOptions) + form, ok, err := dbclient.Client.Forms.Get(ctx, *panel.FormId) + if err != nil { + ctx.HandleError(err) + return + } - 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) - } - } + 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.Id, ctx.UserId(), 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 { diff --git a/bot/button/manager/manager.go b/bot/button/manager/manager.go index 25678ddd..9b0edbdd 100644 --- a/bot/button/manager/manager.go +++ b/bot/button/manager/manager.go @@ -72,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), @@ -94,6 +96,7 @@ func (m *ComponentInteractionManager) RegisterCommands() { ) m.selectRegistry = append(m.selectRegistry, + new(handlers.KBCategorySelectHandler), new(handlers.LanguageSelectorHandler), new(handlers.MultiPanelHandler), new(handlers.PremiumKeyOpenHandler), 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/removesupport.go b/bot/command/impl/settings/removesupport.go index b60b2062..ac007795 100644 --- a/bot/command/impl/settings/removesupport.go +++ b/bot/command/impl/settings/removesupport.go @@ -49,7 +49,6 @@ func (c RemoveSupportCommand) Execute(ctx registry.CommandContext, id uint64) { Inline: false, } - mentionableType, valid := context.DetermineMentionableType(ctx, id) if !valid { ctx.ReplyWithFields(customisation.Red, i18n.Error, i18n.MessageRemoveSupportNoMembers, utils.ToSlice(usageEmbed)) diff --git a/bot/command/manager/manager.go b/bot/command/manager/manager.go index f11423ca..49eee235 100644 --- a/bot/command/manager/manager.go +++ b/bot/command/manager/manager.go @@ -5,6 +5,7 @@ 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/statistics" "github.com/TicketsBot-cloud/worker/bot/command/impl/tags" @@ -49,6 +50,8 @@ func (cm *CommandManager) RegisterCommands() { 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/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/logic/claim.go b/bot/logic/claim.go index 968944dd..e04bb432 100644 --- a/bot/logic/claim.go +++ b/bot/logic/claim.go @@ -104,7 +104,7 @@ func ClaimTicket(ctx context.Context, cmd registry.CommandContext, ticket databa // GenerateClaimedOverwrites If support reps can still view and type, returns (nil, nil) func GenerateClaimedOverwrites(ctx context.Context, worker *worker.Context, ticket database.Ticket, claimer uint64) ([]channel.PermissionOverwrite, error) { // Get per-panel claim settings (SupportCanView/SupportCanType are on the panel) - supportCanView := true // defaults + supportCanView := true // defaults supportCanType := false var additionalPermissions database.TicketPermissions diff --git a/bot/logic/close.go b/bot/logic/close.go index 79020771..1203d00e 100644 --- a/bot/logic/close.go +++ b/bot/logic/close.go @@ -10,13 +10,13 @@ import ( "github.com/TicketsBot-cloud/common/collections" "github.com/TicketsBot-cloud/common/permission" "github.com/TicketsBot-cloud/common/sentry" - botcache "github.com/TicketsBot-cloud/worker/bot/cache" "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/channel/embed" "github.com/TicketsBot-cloud/gdl/objects/channel/message" "github.com/TicketsBot-cloud/gdl/objects/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" diff --git a/bot/metrics/prometheus/prometheus.go b/bot/metrics/prometheus/prometheus.go index 938a45c1..f37ac8b9 100644 --- a/bot/metrics/prometheus/prometheus.go +++ b/bot/metrics/prometheus/prometheus.go @@ -34,7 +34,7 @@ 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") diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 7d42a01c..c22f8556 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -19,9 +19,9 @@ import ( "github.com/TicketsBot-cloud/common/sentry" "github.com/TicketsBot-cloud/gdl/rest/request" "github.com/TicketsBot-cloud/worker/bot/blacklist" - "github.com/TicketsBot-cloud/worker/bot/integrationowners" "github.com/TicketsBot-cloud/worker/bot/cache" "github.com/TicketsBot-cloud/worker/bot/dbclient" + "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" diff --git a/config/config.go b/config/config.go index dfbf9d6e..85bef0f2 100644 --- a/config/config.go +++ b/config/config.go @@ -86,7 +86,7 @@ type ( } `envPrefix:"WORKER_REDIS_"` Streams struct { - GoroutineLimit int `env:"STREAMS_GOROUTINE_LIMIT" envDefault:"1000"` + GoroutineLimit int `env:"STREAMS_GOROUTINE_LIMIT" envDefault:"1000"` } Prometheus struct { diff --git a/event/caller.go b/event/caller.go index 3bd0312c..62bd0c13 100644 --- a/event/caller.go +++ b/event/caller.go @@ -5,20 +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/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") @@ -242,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 @@ -360,10 +408,10 @@ func callCommand( } v.Execute(ctx, arg0) - case settings.ViewStaffCommand: + case settings.SetupCommand: v.Execute(ctx) - case settings.SetupCommand: + case settings.ViewStaffCommand: v.Execute(ctx) case statistics.StatsCommand: @@ -531,13 +579,17 @@ func callCommand( v.Execute(ctx) case tickets.OpenCommand: + var arg0 string + opt0, ok0 := findOption(cmd.Properties().Arguments[0], options) if !ok0 { return ErrArgumentNotFound - } - arg0, ok := opt0.Value.(string) - if !ok { - return fmt.Errorf("option %s was not a string", opt0.Name) + } 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) diff --git a/i18n/messages.go b/i18n/messages.go index 56c9d099..0ef60097 100644 --- a/i18n/messages.go +++ b/i18n/messages.go @@ -367,6 +367,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" From 55ef1b46730ce8be648124352bfb4cae4f52c7ac Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:02:23 +0200 Subject: [PATCH 25/35] Add async claim/unclaim support via message queue Extract duplicated unclaim logic into reusable logic.UnclaimTicket() and logic.ApplyClaim() functions. Add message queue listener to handle claim/unclaim operations asynchronously via claimrelay. Implement atomic claiming using TryClaim() with proper AlreadyClaimedError handling. Refactor ClaimTicket() to separate database claim from channel permission changes. --- bot/button/handlers/claim.go | 7 + bot/button/handlers/unclaim.go | 122 +---------------- bot/command/impl/tickets/claim.go | 7 + bot/command/impl/tickets/unclaim.go | 121 +---------------- bot/listeners/messagequeue/ticketclaim.go | 150 +++++++++++++++++++++ bot/logic/claim.go | 155 +++++++++++++++++++++- bot/logic/open.go | 2 +- cmd/worker/main.go | 1 + i18n/messages.go | 1 + 9 files changed, 321 insertions(+), 245 deletions(-) create mode 100644 bot/listeners/messagequeue/ticketclaim.go 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/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/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/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/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/logic/claim.go b/bot/logic/claim.go index 9cff45e0..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,6 +134,126 @@ 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) { diff --git a/bot/logic/open.go b/bot/logic/open.go index 3bb9eab5..a8ef4324 100644 --- a/bot/logic/open.go +++ b/bot/logic/open.go @@ -878,7 +878,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(), diff --git a/cmd/worker/main.go b/cmd/worker/main.go index c22f8556..256ae13c 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -151,6 +151,7 @@ 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() diff --git a/i18n/messages.go b/i18n/messages.go index 0ef60097..d1d8749e 100644 --- a/i18n/messages.go +++ b/i18n/messages.go @@ -132,6 +132,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" From 5143b6b1a30d558964d4de009d089d174e351d9c Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:05:03 +0200 Subject: [PATCH 26/35] Fix ticket transfer claim reassignment Transfer now overwrites the existing claim for the target user before applying the claim logic, so ticket ownership is reassigned instead of failing on an existing claim. --- bot/command/impl/tickets/transfer.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 } From b38e14df75219df50e19226a3f799684bca24a31 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:36:02 +0200 Subject: [PATCH 27/35] Handle intents rejection in WL resync Update the whitelabel resync button handler to detect intents rejection errors from `ReapplyIntents` and reply with a clear red error message instead of falling back to generic error handling. Other errors still use `ctx.HandleError(err)`. --- bot/button/handlers/whitelabel/resync.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/button/handlers/whitelabel/resync.go b/bot/button/handlers/whitelabel/resync.go index 55186b22..17781372 100644 --- a/bot/button/handlers/whitelabel/resync.go +++ b/bot/button/handlers/whitelabel/resync.go @@ -61,7 +61,12 @@ func (h *WhitelabelResyncHandler) Execute(ctx *context.ButtonContext) { } if err := commonwl.ReapplyIntents(ctx, bot.Token); err != nil { - ctx.HandleError(err) + if commonwl.IsIntentsRejection(err) { + ctx.ReplyRaw(customisation.Red, "Error", commonwl.IntentsRejectedMessage) + } else { + ctx.HandleError(err) + } + return } From fc7e6bb726bbf716352d743e3a577d575ed746a8 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 15 Aug 2026 08:08:15 +0100 Subject: [PATCH 28/35] growthbook & rm kb Signed-off-by: Ben --- bot/exposures/retention.go | 80 ++++++++++++++++++++ bot/metrics/prometheus/featureflagmetrics.go | 52 +++++++++++++ bot/utils/featureflags.go | 12 +++ cmd/worker/main.go | 53 +++++++++++++ config/config.go | 10 +++ go.mod | 2 + go.sum | 4 + 7 files changed, 213 insertions(+) create mode 100644 bot/exposures/retention.go create mode 100644 bot/metrics/prometheus/featureflagmetrics.go create mode 100644 bot/utils/featureflags.go 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/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/utils/featureflags.go b/bot/utils/featureflags.go new file mode 100644 index 00000000..b9c3dd78 --- /dev/null +++ b/bot/utils/featureflags.go @@ -0,0 +1,12 @@ +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 and evaluate every flag to off. +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/cmd/worker/main.go b/cmd/worker/main.go index 256ae13c..dd078637 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,18 @@ 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" @@ -129,6 +133,41 @@ 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 and evaluation degrades to every flag off. + 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, all flags will evaluate to off", zap.Error(err)) + } + logger.Info("Starting Prometheus server") prometheus.StartServer(config.Conf.Prometheus.Address) logger.Info("Started Prometheus server") @@ -159,6 +198,8 @@ func main() { 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))) @@ -216,6 +257,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 85bef0f2..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" @@ -93,6 +94,15 @@ type ( 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/go.mod b/go.mod index fec80640..8541f1cb 100644 --- a/go.mod +++ b/go.mod @@ -77,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 @@ -117,6 +118,7 @@ require ( 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 diff --git a/go.sum b/go.sum index 2bcdd79b..66bb7569 100644 --- a/go.sum +++ b/go.sum @@ -142,6 +142,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= @@ -344,6 +346,8 @@ github.com/tatsuworks/czlib v0.0.0-20190916144400-8a51758ea0d9 h1:i2aD44Moa5N5pt github.com/tatsuworks/czlib v0.0.0-20190916144400-8a51758ea0d9/go.mod h1:6HrfShlf4bKeQEFdWn4JP/yet/mHW2RhxOQf0e3HWA0= 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= From ccce6346eb2264237ae3255c09765f8b1141bf3a Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:57:00 +0200 Subject: [PATCH 29/35] Enhance form API placeholders and request caching Refactors form API option loading to build context-aware placeholders (user, guild, member, permission, locale, panel) and safely substitute them with URL/header/JSON escaping. Adds POST body template support with auto-generated default payloads, parallel option fetches, and a hashed cache key based on the fully substituted request to prevent cross-user cache leakage. --- bot/button/handlers/formapiconfig.go | 156 ++++++++++++++++----- bot/button/handlers/formapiplaceholders.go | 77 ++++++++++ bot/button/handlers/multipanel.go | 2 +- bot/button/handlers/opensurvey.go | 2 +- bot/button/handlers/panel.go | 2 +- bot/command/impl/tickets/open.go | 2 +- bot/integrations/placeholders.go | 60 ++++++++ 7 files changed, 263 insertions(+), 38 deletions(-) create mode 100644 bot/button/handlers/formapiplaceholders.go create mode 100644 bot/integrations/placeholders.go diff --git a/bot/button/handlers/formapiconfig.go b/bot/button/handlers/formapiconfig.go index 74e46187..0a435f1b 100644 --- a/bot/button/handlers/formapiconfig.go +++ b/bot/button/handlers/formapiconfig.go @@ -2,15 +2,18 @@ package handlers import ( "context" + "crypto/sha256" "encoding/json" "fmt" - "strconv" + "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" ) @@ -21,6 +24,23 @@ type apiOption struct { 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 @@ -33,8 +53,16 @@ var apiOptionsCache = struct { items: make(map[string]apiOptionsCacheEntry), } -func FetchApiOptions(ctx context.Context, formId int, userId uint64, inputs []database.FormInput, inputOptions map[int][]database.FormInputOption) { - configs, err := dbclient.Client.FormInputApiConfig.GetByFormId(ctx, formId) +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 @@ -49,28 +77,86 @@ func FetchApiOptions(ctx context.Context, formId int, userId uint64, inputs []da 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 } - options, err := fetchOptionsFromApi(ctx, cfg, userId) - if err != nil { - sentry.Error(err) - options = fallbackOptions(cfg) - } + wg.Add(1) + go func() { + defer wg.Done() - if len(options) == 0 { - options = fallbackOptions(cfg) - } + options, err := fetchOptionsFromApi(ctx, cfg, input, placeholders) + if err != nil { + sentry.Error(err) + options = fallbackOptions(cfg) + } + + if len(options) == 0 { + options = fallbackOptions(cfg) + } - inputOptions[input.Id] = options + lock.Lock() + inputOptions[input.Id] = options + lock.Unlock() + }() } + + wg.Wait() } -func fetchOptionsFromApi(ctx context.Context, cfg database.FormInputApiConfig, userId uint64) ([]database.FormInputOption, error) { - cacheKey := fmt.Sprintf("%d:%d", cfg.Id, userId) +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] @@ -85,22 +171,7 @@ func fetchOptionsFromApi(ctx context.Context, cfg database.FormInputApiConfig, u apiOptionsCache.Unlock() } - url := substituteplaceholders(cfg.EndpointUrl, userId) - - 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] = substituteplaceholders(h.HeaderValue, userId) - } - - res, err := integrations.SecureProxy.DoRequest(ctx, cfg.Method, url, headerMap, nil) + res, err := integrations.SecureProxy.DoRequest(ctx, cfg.Method, url, headerMap, body) if err != nil { return nil, err } @@ -137,6 +208,27 @@ func fetchOptionsFromApi(ctx context.Context, cfg database.FormInputApiConfig, u 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) @@ -158,7 +250,3 @@ func fallbackOptions(cfg database.FormInputApiConfig) []database.FormInputOption }, } } - -func substituteplaceholders(s string, userId uint64) string { - return strings.ReplaceAll(s, "%user_id%", strconv.FormatUint(userId, 10)) -} 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/multipanel.go b/bot/button/handlers/multipanel.go index b13ab361..edf3a9cf 100644 --- a/bot/button/handlers/multipanel.go +++ b/bot/button/handlers/multipanel.go @@ -93,7 +93,7 @@ func (h *MultiPanelHandler) Execute(ctx *context.SelectMenuContext) { return } - FetchApiOptions(ctx, form.Id, ctx.UserId(), inputs, inputOptions) + 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) diff --git a/bot/button/handlers/opensurvey.go b/bot/button/handlers/opensurvey.go index f78c275b..3e9c2a78 100644 --- a/bot/button/handlers/opensurvey.go +++ b/bot/button/handlers/opensurvey.go @@ -126,7 +126,7 @@ func (h *OpenSurveyHandler) Execute(ctx *context.ButtonContext) { return } - FetchApiOptions(ctx, form.Id, ctx.UserId(), formInputs, inputOptions) + FetchApiOptions(ctx, form, panel, formInputs, inputOptions) ctx.Modal(button.ResponseModal{ Data: interaction.ModalResponseData{ diff --git a/bot/button/handlers/panel.go b/bot/button/handlers/panel.go index 2b8d3530..75431c54 100644 --- a/bot/button/handlers/panel.go +++ b/bot/button/handlers/panel.go @@ -103,7 +103,7 @@ func openPanelOrForm(ctx *context.ButtonContext, panel database.Panel, outOfHour return } - FetchApiOptions(ctx, form.Id, ctx.UserId(), inputs, inputOptions) + 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) diff --git a/bot/command/impl/tickets/open.go b/bot/command/impl/tickets/open.go index b55f0440..3601c6bb 100644 --- a/bot/command/impl/tickets/open.go +++ b/bot/command/impl/tickets/open.go @@ -146,7 +146,7 @@ func openWithPanel(ctx *cmdcontext.SlashCommandContext, panel database.Panel) { return } - handlers.FetchApiOptions(ctx, form.Id, ctx.UserId(), inputs, inputOptions) + 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) 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) +} From 1391f3d331f0555d63d264448a4e27e7072ee67f Mon Sep 17 00:00:00 2001 From: Ben Hall Date: Thu, 20 Aug 2026 08:05:55 +0100 Subject: [PATCH 30/35] feat: kill switch feature flags Signed-off-by: Ben Hall --- bot/logic/open.go | 12 ++++++++++++ bot/utils/featureflags.go | 3 ++- cmd/worker/main.go | 31 +++++++++++++++++++++++++++++-- i18n/messages.go | 1 + 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/bot/logic/open.go b/bot/logic/open.go index a8ef4324..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" @@ -37,6 +38,17 @@ import ( ) 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() diff --git a/bot/utils/featureflags.go b/bot/utils/featureflags.go index b9c3dd78..92322447 100644 --- a/bot/utils/featureflags.go +++ b/bot/utils/featureflags.go @@ -4,7 +4,8 @@ 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 and evaluate every flag to off. +// 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 diff --git a/cmd/worker/main.go b/cmd/worker/main.go index dd078637..7313a425 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -156,7 +156,13 @@ func main() { ) // A GrowthBook outage must not stop the worker booting, so a failure here is - // logged and evaluation degrades to every flag off. + // 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, @@ -165,7 +171,28 @@ func main() { utils.ExposureRecorder, ) if err != nil { - logger.Error("Failed to configure feature flags, all flags will evaluate to off", zap.Error(err)) + 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") diff --git a/i18n/messages.go b/i18n/messages.go index d1d8749e..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" From 2e54aa1e0451e69cdd19816965ba8192addbbe51 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:57:41 +0200 Subject: [PATCH 31/35] Handle nil ticket channel in placeholder Update the `channel` welcome-message substitution to safely handle tickets without a channel ID. It now returns an empty string when `ticket.ChannelId` is nil, preventing nil pointer dereferences when rendering placeholders. --- bot/logic/welcomemessage.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/logic/welcomemessage.go b/bot/logic/welcomemessage.go index 40133d4a..51190a5b 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -443,7 +443,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) From b9371204f46ba78c002cde70b14fff6e62598939 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:00:21 +0200 Subject: [PATCH 32/35] Handle plain-text placeholders in embeds Refactors placeholder substitution to support a plain-text mode that skips markup-only placeholders for embed text fields. Custom embed building now applies the right substitution per field, truncates text fields to Discord embed limits using rune-safe truncation, and consistently resolves `%avatar_url%` with a default fallback when user data is missing or lookup fails. --- bot/logic/welcomemessage.go | 138 ++++++++++++++++++++++++++++++------ 1 file changed, 116 insertions(+), 22 deletions(-) diff --git a/bot/logic/welcomemessage.go b/bot/logic/welcomemessage.go index 51190a5b..0b8a00ea 100644 --- a/bot/logic/welcomemessage.go +++ b/bot/logic/welcomemessage.go @@ -157,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_]+(?::[^%\\]+)?)\\%`) @@ -172,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 @@ -182,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) { @@ -400,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) @@ -418,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, ":") @@ -432,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) @@ -641,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, @@ -650,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), } @@ -666,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) From 271e4e67995912c29cfd6993cb6acf37a0d988ec Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:27:01 +0200 Subject: [PATCH 33/35] Require guild_id for admin recache command Updated `adminrecache` to require a `guild_id` argument instead of falling back to the current guild. The command now parses a raw string argument directly and returns a user-facing error message when the ID is invalid. Command dispatch in `event/caller.go` was adjusted accordingly to enforce argument presence (`ErrArgumentNotFound`) and pass a non-pointer string value. --- bot/command/impl/admin/adminrecache.go | 18 ++++++------------ event/caller.go | 6 +++--- 2 files changed, 9 insertions(+), 15 deletions(-) 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/event/caller.go b/event/caller.go index 62bd0c13..761e72a9 100644 --- a/event/caller.go +++ b/event/caller.go @@ -133,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) From 52b8b8c6bd1347ed9689fa8348834b036a7962fd Mon Sep 17 00:00:00 2001 From: Ben Hall Date: Thu, 27 Aug 2026 07:48:55 +0100 Subject: [PATCH 34/35] go mod tidy Signed-off-by: Ben Hall --- go.mod | 22 +++++++++++----------- go.sum | 38 ++++++++++++++++++++------------------ 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index 8541f1cb..b9015775 100644 --- a/go.mod +++ b/go.mod @@ -15,8 +15,8 @@ replace github.com/TicketsBot-cloud/common => ../common require ( cloud.google.com/go/profiler v0.4.2 github.com/TicketsBot-cloud/archiverclient v0.0.0-20251015181023-f0b66a074704 - github.com/TicketsBot-cloud/common v0.0.0-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 @@ -35,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 ) @@ -128,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 66bb7569..9ec64cbc 100644 --- a/go.sum +++ b/go.sum @@ -392,8 +392,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= @@ -408,8 +410,8 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 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= @@ -417,8 +419,8 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 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= @@ -428,15 +430,15 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug 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-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= @@ -456,16 +458,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= @@ -475,8 +477,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= @@ -490,8 +492,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 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= From 298aba1a798c29854b80124efcac1b730c6eda12 Mon Sep 17 00:00:00 2001 From: Ben Hall Date: Thu, 27 Aug 2026 08:35:36 +0100 Subject: [PATCH 35/35] go mod tidy Signed-off-by: Ben Hall --- go.mod | 4 ++-- go.sum | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index b9015775..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 diff --git a/go.sum b/go.sum index 9ec64cbc..7acfe894 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,10 @@ github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0 github.com/ReneKroon/ttlcache v1.6.0/go.mod h1:DG6nbhXKUQhrExfwwLuZUdH7UnRDDRA1IW+nBuCssvs= 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-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=