From ee982be586be03060cf53c8babbd6c6d30a45cb0 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:00:13 +0200 Subject: [PATCH] Preserve claimer overwrites Keep the claimer's existing member overwrite when unclaiming switch-panel tickets, and pin claimer access explicitly when claimed tickets need regenerated permissions. Also refactor claimed-ticket overwrite generation to build and upsert the claimer overwrite consistently. --- bot/button/handlers/unclaim.go | 17 +- bot/command/impl/tickets/switchpanel.go | 14 +- bot/command/impl/tickets/unclaim.go | 17 +- bot/logic/claim.go | 227 +++++++++++++++++++++--- 4 files changed, 233 insertions(+), 42 deletions(-) diff --git a/bot/button/handlers/unclaim.go b/bot/button/handlers/unclaim.go index 3c37347f..33a1ebed 100644 --- a/bot/button/handlers/unclaim.go +++ b/bot/button/handlers/unclaim.go @@ -145,12 +145,17 @@ func (h *UnclaimHandler) Execute(ctx *context.ButtonContext) { switch claimSettings.SwitchPanelClaimBehavior { case database.SwitchPanelKeepAccess: - overwrites = append(overwrites, channel.PermissionOverwrite{ - Id: whoClaimed, - Type: channel.PermissionTypeMember, - Allow: discordpermission.BuildPermissions(logic.StandardPermissions[:]...), - Deny: 0, - }) + // 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, diff --git a/bot/command/impl/tickets/switchpanel.go b/bot/command/impl/tickets/switchpanel.go index bacc0094..2800255f 100644 --- a/bot/command/impl/tickets/switchpanel.go +++ b/bot/command/impl/tickets/switchpanel.go @@ -292,15 +292,21 @@ func (SwitchPanelCommand) Execute(ctx *cmdcontext.SlashCommandContext, panelId i return } - // GenerateClaimedOverwrites returns nil if the permissions are the same as an unclaimed ticket - // so if this is the case, we still need to calculate permissions + // If nil, calculate the base permissions and pin the claimer at the user level if overwrites == nil { - membersWithClaimer := append(members, claimer) - overwrites, err = logic.CreateOverwrites(ctx.Context, ctx, ticket.UserId, &newPanel, newPanel.TargetCategory, membersWithClaimer...) + overwrites, err = logic.CreateOverwrites(ctx.Context, ctx, ticket.UserId, &newPanel, newPanel.TargetCategory, members...) if err != nil { ctx.HandleError(err) return } + + claimerOverwrite, err := logic.BuildClaimerOverwrite(ctx.Context, ctx.Worker(), ticket, claimer) + if err != nil { + ctx.HandleError(err) + return + } + + overwrites = logic.UpsertMemberOverwrite(overwrites, claimerOverwrite) } } diff --git a/bot/command/impl/tickets/unclaim.go b/bot/command/impl/tickets/unclaim.go index dd7906c6..422f7eb0 100644 --- a/bot/command/impl/tickets/unclaim.go +++ b/bot/command/impl/tickets/unclaim.go @@ -141,12 +141,17 @@ func (UnclaimCommand) Execute(ctx *context.SlashCommandContext) { switch claimSettings.SwitchPanelClaimBehavior { case database.SwitchPanelKeepAccess: - overwrites = append(overwrites, channel.PermissionOverwrite{ - Id: whoClaimed, - Type: channel.PermissionTypeMember, - Allow: discordpermission.BuildPermissions(logic.StandardPermissions[:]...), - Deny: 0, - }) + // 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, diff --git a/bot/logic/claim.go b/bot/logic/claim.go index a580ad1f..3f6a38aa 100644 --- a/bot/logic/claim.go +++ b/bot/logic/claim.go @@ -17,6 +17,7 @@ import ( "github.com/TicketsBot-cloud/worker/bot/command/registry" "github.com/TicketsBot-cloud/worker/bot/customisation" "github.com/TicketsBot-cloud/worker/bot/dbclient" + "github.com/TicketsBot-cloud/worker/bot/utils" "github.com/TicketsBot-cloud/worker/i18n" "golang.org/x/sync/errgroup" ) @@ -76,32 +77,40 @@ func ClaimTicket(ctx context.Context, cmd registry.CommandContext, ticket databa shouldUpdateName = false } + // Pin the claimer's access at the user level + if newOverwrites == nil { + claimerOverwrite, err := BuildClaimerOverwrite(ctx, cmd.Worker(), ticket, userId) + if err != nil { + return err + } + + newOverwrites = UpsertMemberOverwrite(currentChannel.PermissionOverwrites, claimerOverwrite) + } + // Update channel permissions and name - data := rest.ModifyChannelData{} - if newOverwrites != nil { - data.PermissionOverwrites = newOverwrites + data := rest.ModifyChannelData{ + PermissionOverwrites: newOverwrites, } if shouldUpdateName { data.Name = newChannelName } - if newOverwrites != nil || shouldUpdateName { - claimer, err := cmd.Worker().GetGuildMember(ticket.GuildId, userId) - auditReason := fmt.Sprintf("Claimed ticket %d", ticket.Id) - if err == nil { - auditReason = fmt.Sprintf("Claimed ticket %d by %s", ticket.Id, claimer.User.Username) - } + claimer, err := cmd.Worker().GetGuildMember(ticket.GuildId, userId) + auditReason := fmt.Sprintf("Claimed ticket %d", ticket.Id) + if err == nil { + auditReason = fmt.Sprintf("Claimed ticket %d by %s", ticket.Id, claimer.User.Username) + } - reasonCtx := request.WithAuditReason(context.Background(), auditReason) - if _, err = cmd.Worker().ModifyChannel(reasonCtx, *ticket.ChannelId, data); err != nil { - return err - } + reasonCtx := request.WithAuditReason(context.Background(), auditReason) + if _, err = cmd.Worker().ModifyChannel(reasonCtx, *ticket.ChannelId, data); err != nil { + return err } return nil } -// GenerateClaimedOverwrites If support reps can still view and type, returns (nil, nil) +// GenerateClaimedOverwrites returns the full overwrite set for a claimed ticket, or +// (nil, nil) if support reps can still view and type. func GenerateClaimedOverwrites(ctx context.Context, worker *worker.Context, ticket database.Ticket, claimer uint64) ([]channel.PermissionOverwrite, error) { // Get claim settings for guild claimSettings, err := dbclient.Client.ClaimSettings.Get(ctx, ticket.GuildId) @@ -133,9 +142,15 @@ func GenerateClaimedOverwrites(ctx context.Context, worker *worker.Context, tick return nil, err } + // Build the overwrite for the claimer based on their team permissions + claimerOverwrite, err := BuildClaimerOverwrite(ctx, worker, ticket, claimer) + if err != nil { + return nil, err + } + // Support can't view the ticket, and therefore can't type either if !claimSettings.SupportCanView { - return overwritesCantView(claimer, worker.BotId, ticket.UserId, ticket.GuildId, adminUsers, adminRoles, integrationRoleId, additionalPermissions), nil + return overwritesCantView(claimerOverwrite, claimer, worker.BotId, ticket.UserId, ticket.GuildId, adminUsers, adminRoles, integrationRoleId, additionalPermissions), nil } // Support can view the ticket, but can't type @@ -180,16 +195,166 @@ func GenerateClaimedOverwrites(ctx context.Context, worker *worker.Context, tick } } - return overwritesCantType(claimer, worker.BotId, ticket.UserId, ticket.GuildId, supportUsers, supportRoles, adminUsers, adminRoles, integrationRoleId, additionalPermissions), nil + return overwritesCantType(claimerOverwrite, claimer, worker.BotId, ticket.UserId, ticket.GuildId, supportUsers, supportRoles, adminUsers, adminRoles, integrationRoleId, additionalPermissions), nil } // Unreachable return nil, fmt.Errorf("unreachable code reached") } +// BuildClaimerOverwrite returns the user-level permission overwrite granted to the +// claimer. Admins and default-team members receive the full StandardPermissions set; +// custom team members receive the union of their teams' permissions, with SendMessages +// force-allowed. +func BuildClaimerOverwrite(ctx context.Context, worker *worker.Context, ticket database.Ticket, claimerId uint64) (channel.PermissionOverwrite, error) { + standard := channel.PermissionOverwrite{ + Id: claimerId, + Type: channel.PermissionTypeMember, + Allow: permission.BuildPermissions(StandardPermissions[:]...), + Deny: 0, + } + + // Admins always get the full permission set + isAdmin, err := IsAdminForGuild(ctx, worker, ticket.GuildId, claimerId) + if err != nil { + return channel.PermissionOverwrite{}, err + } + + if isAdmin { + return standard, nil + } + + defaultTeam, claimerTeamIds, err := GetMemberTeams(ctx, worker, ticket.GuildId, claimerId) + if err != nil { + return channel.PermissionOverwrite{}, err + } + + var panel *database.Panel + if ticket.PanelId != nil { + tmp, err := dbclient.Client.Panel.GetById(ctx, *ticket.PanelId) + if err != nil { + return channel.PermissionOverwrite{}, err + } + + if tmp.PanelId != 0 { + panel = &tmp + } + } + + // Default-team members always get the full permission set + if defaultTeam && (panel == nil || panel.WithDefaultTeam) { + return standard, nil + } + + // Only consider the teams linked to this ticket's panel + var relevantTeamIds []int + if panel != nil { + panelTeamIds, err := dbclient.Client.PanelTeams.GetTeamIds(ctx, panel.PanelId) + if err != nil { + return channel.PermissionOverwrite{}, err + } + + for _, teamId := range panelTeamIds { + if utils.Contains(claimerTeamIds, teamId) { + relevantTeamIds = append(relevantTeamIds, teamId) + } + } + } + + // Preserve the claimer's existing overwrite, falling back to the full set + if len(relevantTeamIds) == 0 { + if ticket.ChannelId != nil { + ch, err := worker.GetChannel(*ticket.ChannelId) + if err != nil { + return channel.PermissionOverwrite{}, err + } + + if existing, ok := FindMemberOverwrite(ch.PermissionOverwrites, claimerId); ok { + return existing, nil + } + } + + return standard, nil + } + + teamPermsMap, err := dbclient.Client.SupportTeamPermissions.GetForTeams(ctx, relevantTeamIds) + if err != nil { + return channel.PermissionOverwrite{}, err + } + + // Union (most permissive) across the claimer's relevant teams + var union database.SupportTeamPermissions + for _, teamId := range relevantTeamIds { + perms, ok := teamPermsMap[teamId] + if !ok { + // Default permissions for teams with no configured entry + perms = database.SupportTeamPermissions{ + AddReactions: true, + SendMessages: true, + SendTTSMessages: true, + EmbedLinks: true, + AttachFiles: true, + MentionEveryone: false, + UseExternalEmojis: true, + UseApplicationCommands: true, + UseExternalStickers: true, + SendVoiceMessages: true, + } + } + + union.AddReactions = union.AddReactions || perms.AddReactions + union.SendMessages = union.SendMessages || perms.SendMessages + union.SendTTSMessages = union.SendTTSMessages || perms.SendTTSMessages + union.EmbedLinks = union.EmbedLinks || perms.EmbedLinks + union.AttachFiles = union.AttachFiles || perms.AttachFiles + union.MentionEveryone = union.MentionEveryone || perms.MentionEveryone + union.UseExternalEmojis = union.UseExternalEmojis || perms.UseExternalEmojis + union.UseApplicationCommands = union.UseApplicationCommands || perms.UseApplicationCommands + union.UseExternalStickers = union.UseExternalStickers || perms.UseExternalStickers + union.SendVoiceMessages = union.SendVoiceMessages || perms.SendVoiceMessages + } + + // Force-allow SendMessages so the claimer can always respond + union.SendMessages = true + + allow, deny := buildStaffPermissions(union) + return channel.PermissionOverwrite{ + Id: claimerId, + Type: channel.PermissionTypeMember, + Allow: permission.BuildPermissions(allow...), + Deny: permission.BuildPermissions(deny...), + }, nil +} + +// FindMemberOverwrite returns the member-level overwrite for the given user, if present. +func FindMemberOverwrite(overwrites []channel.PermissionOverwrite, userId uint64) (channel.PermissionOverwrite, bool) { + for _, ow := range overwrites { + if ow.Id == userId && ow.Type == channel.PermissionTypeMember { + return ow, true + } + } + + return channel.PermissionOverwrite{}, false +} + +// UpsertMemberOverwrite replaces any existing overwrite for the same target with the +// given one, appending it if not already present. +func UpsertMemberOverwrite(overwrites []channel.PermissionOverwrite, overwrite channel.PermissionOverwrite) []channel.PermissionOverwrite { + result := make([]channel.PermissionOverwrite, 0, len(overwrites)+1) + for _, ow := range overwrites { + if ow.Id == overwrite.Id && ow.Type == overwrite.Type { + continue + } + + result = append(result, ow) + } + + return append(result, overwrite) +} + // We should build new overwrites from scratch // TODO: Instead of append(), set indices -func overwritesCantView(claimer, selfId, openerId, guildId uint64, adminUsers, adminRoles []uint64, integrationRoleId *uint64, additionalPermissions database.TicketPermissions) (overwrites []channel.PermissionOverwrite) { +func overwritesCantView(claimerOverwrite channel.PermissionOverwrite, claimerId, selfId, openerId, guildId uint64, adminUsers, adminRoles []uint64, integrationRoleId *uint64, additionalPermissions database.TicketPermissions) (overwrites []channel.PermissionOverwrite) { overwrites = append(overwrites, BuildUserOverwrite(openerId, additionalPermissions), channel.PermissionOverwrite{ // @everyone Id: guildId, @@ -199,15 +364,13 @@ func overwritesCantView(claimer, selfId, openerId, guildId uint64, adminUsers, a }, ) - // Add claimer to ticket, and attempt to add self by user - adminUserTargets := make([]uint64, len(adminUsers)+1, len(adminUsers)+2) + // Attempt to add self by user + adminUserTargets := make([]uint64, len(adminUsers), len(adminUsers)+1) adminRoleTargets := make([]uint64, len(adminRoles), len(adminRoles)+1) copy(adminUserTargets, adminUsers) copy(adminRoleTargets, adminRoles) - adminUserTargets[len(adminUserTargets)-1] = claimer - if integrationRoleId == nil { adminUserTargets = append(adminUserTargets, selfId) } else { @@ -216,6 +379,11 @@ func overwritesCantView(claimer, selfId, openerId, guildId uint64, adminUsers, a // Build overwrites for _, userId := range adminUserTargets { + // The claimer gets their own overwrite appended below + if userId == claimerId { + continue + } + overwrites = append(overwrites, channel.PermissionOverwrite{ Id: userId, Type: channel.PermissionTypeMember, @@ -233,6 +401,8 @@ func overwritesCantView(claimer, selfId, openerId, guildId uint64, adminUsers, a }) } + overwrites = append(overwrites, claimerOverwrite) + return } @@ -240,7 +410,7 @@ var readOnlyAllowed = []permission.Permission{permission.ViewChannel, permission var readOnlyDenied = []permission.Permission{permission.SendMessages, permission.AddReactions} // support & admins are not mutually exclusive due to support teams -func overwritesCantType(claimerId, selfId, openerId, guildId uint64, supportUsers, supportRoles, adminUsers, adminRoles []uint64, integrationRoleId *uint64, additionalPermissions database.TicketPermissions) (overwrites []channel.PermissionOverwrite) { +func overwritesCantType(claimerOverwrite channel.PermissionOverwrite, claimerId, selfId, openerId, guildId uint64, supportUsers, supportRoles, adminUsers, adminRoles []uint64, integrationRoleId *uint64, additionalPermissions database.TicketPermissions) (overwrites []channel.PermissionOverwrite) { overwrites = append(overwrites, BuildUserOverwrite(openerId, additionalPermissions), channel.PermissionOverwrite{ // @everyone Id: guildId, @@ -250,15 +420,13 @@ func overwritesCantType(claimerId, selfId, openerId, guildId uint64, supportUser }, ) - // Add claimer to ticket, and attempt to add self by user - adminUserTargets := make([]uint64, len(adminUsers)+1, len(adminUsers)+2) + // Attempt to add self by user + adminUserTargets := make([]uint64, len(adminUsers), len(adminUsers)+1) adminRoleTargets := make([]uint64, len(adminRoles), len(adminRoles)+1) copy(adminUserTargets, adminUsers) copy(adminRoleTargets, adminRoles) - adminUserTargets[len(adminUserTargets)-1] = claimerId - if integrationRoleId == nil { adminUserTargets = append(adminUserTargets, selfId) } else { @@ -266,6 +434,11 @@ func overwritesCantType(claimerId, selfId, openerId, guildId uint64, supportUser } for _, userId := range adminUserTargets { + // The claimer gets their own overwrite appended below + if userId == claimerId { + continue + } + overwrites = append(overwrites, channel.PermissionOverwrite{ Id: userId, Type: channel.PermissionTypeMember, @@ -323,6 +496,8 @@ func overwritesCantType(claimerId, selfId, openerId, guildId uint64, supportUser }) } + overwrites = append(overwrites, claimerOverwrite) + return }