From 313f80b4d5ef36cfd67a8fc0d50506c8a5aa85a2 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 8 Aug 2026 13:20:00 -0700 Subject: [PATCH 1/6] feat(discord): archive channels moved to ARCHIVE category with unarchive command --- discord/commands/handler.go | 28 +++++ discord/commands/unarchive.go | 40 +++++++ discord/config/config.go | 4 + discord/database/db.go | 1 + discord/model/archived_channel.go | 18 ++++ discord/service/channel_archive.go | 163 +++++++++++++++++++++++++++++ 6 files changed, 254 insertions(+) create mode 100644 discord/commands/unarchive.go create mode 100644 discord/model/archived_channel.go create mode 100644 discord/service/channel_archive.go diff --git a/discord/commands/handler.go b/discord/commands/handler.go index 751865d..bcbe5bc 100644 --- a/discord/commands/handler.go +++ b/discord/commands/handler.go @@ -29,6 +29,7 @@ func InitializeBot() { service.Discord.AddHandler(OnGuildMemberRemove) service.Discord.AddHandler(OnUserUpdate) service.Discord.AddHandler(OnThreadUpdate) + service.Discord.AddHandler(OnChannelUpdate) service.Discord.Identify.Intents = discordgo.MakeIntent(discordgo.IntentsAll) err := service.Discord.Open() if err != nil { @@ -72,6 +73,8 @@ func OnDiscordMessage(s *discordgo.Session, m *discordgo.MessageCreate) { Ping(args, s, m) case "verify": Verify(args, s, m) + case "unarchive": + Unarchive(args, s, m) default: logger.SugarLogger.Infof("Unknown command: %s", command) } @@ -185,6 +188,31 @@ func OnThreadUpdate(s *discordgo.Session, t *discordgo.ThreadUpdate) { service.KeepThreadAlive(t.Channel) } +// OnChannelUpdate archives channels that get moved into the archive category: +// permissions are synced to the category's and the prior state is snapshotted +// for the unarchive command. Only genuine moves are handled — the permission +// sync itself emits another ChannelUpdate with an unchanged parent, which the +// BeforeUpdate guard filters out (no loop). BeforeUpdate can be nil if the +// channel wasn't in the state cache; ArchiveChannel is idempotent so the +// worst case there is a re-sync and a duplicate notice. +func OnChannelUpdate(s *discordgo.Session, c *discordgo.ChannelUpdate) { + if c.GuildID != config.DiscordGuild || c.Type == discordgo.ChannelTypeGuildCategory { + return + } + if c.ParentID == "" || !service.IsArchiveCategory(c.ParentID) { + return + } + if c.BeforeUpdate != nil && c.BeforeUpdate.ParentID == c.ParentID { + return + } + previousParentID := "" + if c.BeforeUpdate != nil { + previousParentID = c.BeforeUpdate.ParentID + } + logger.SugarLogger.Infof("ChannelUpdate: channel %s (%s) was moved into the archive category", c.ID, c.Name) + service.ArchiveChannel(c.Channel, previousParentID) +} + func diffRoles(before, after []string) (added, removed []string) { beforeSet := make(map[string]struct{}, len(before)) for _, r := range before { diff --git a/discord/commands/unarchive.go b/discord/commands/unarchive.go new file mode 100644 index 0000000..f55f7d6 --- /dev/null +++ b/discord/commands/unarchive.go @@ -0,0 +1,40 @@ +package commands + +import ( + "fmt" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/gaucho-racing/sentinel/discord/pkg/logger" + "github.com/gaucho-racing/sentinel/discord/service" +) + +const unarchiveReplyTTL = 10 * time.Second + +func Unarchive(args []string, s *discordgo.Session, m *discordgo.MessageCreate) { + permissions, err := s.UserChannelPermissions(m.Author.ID, m.ChannelID) + if err != nil { + logger.SugarLogger.Errorf("unarchive: failed to compute permissions for %s in %s: %v", m.Author.ID, m.ChannelID, err) + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> something went wrong, try again in a minute.", m.Author.ID), unarchiveReplyTTL) + return + } + if permissions&discordgo.PermissionManageChannels == 0 { + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> you need the Manage Channels permission to unarchive this channel.", m.Author.ID), unarchiveReplyTTL) + return + } + + record, err := service.UnarchiveChannel(m.ChannelID) + if err != nil { + logger.SugarLogger.Errorf("unarchive: failed for channel %s: %v", m.ChannelID, err) + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> this channel isn't archived, or restoring it failed — check the logs.", m.Author.ID), unarchiveReplyTTL) + return + } + + content := "This channel has been unarchived and its permissions restored." + if record.PreviousParentID == "" { + content += " I couldn't determine its original category, so it'll need to be moved manually." + } + if _, err := s.ChannelMessageSend(m.ChannelID, content); err != nil { + logger.SugarLogger.Errorf("unarchive: failed to send confirmation in %s: %v", m.ChannelID, err) + } +} diff --git a/discord/config/config.go b/discord/config/config.go index 549c3a5..395378e 100644 --- a/discord/config/config.go +++ b/discord/config/config.go @@ -70,6 +70,10 @@ func IsProduction() bool { return Env == "PROD" } +// DiscordArchiveCategoryName is the channel category (matched by name, +// case-insensitive) that channels get moved into to archive them. +const DiscordArchiveCategoryName = "ARCHIVE" + var MembersDiscordRoleID = "820467859477889034" var AlumniDiscordRoleID = "817577502968512552" var GuestDiscordRoleID = "1511273081824477245" diff --git a/discord/database/db.go b/discord/database/db.go index c31e61d..9b8369e 100644 --- a/discord/database/db.go +++ b/discord/database/db.go @@ -34,6 +34,7 @@ func Init() { &model.DiscordReaction{}, &model.OnboardingToken{}, &model.GroupDiscordRoleBinding{}, + &model.ArchivedChannel{}, ) logger.SugarLogger.Infoln("AutoMigration complete") DB = db diff --git a/discord/model/archived_channel.go b/discord/model/archived_channel.go new file mode 100644 index 0000000..12f5c16 --- /dev/null +++ b/discord/model/archived_channel.go @@ -0,0 +1,18 @@ +package model + +import "time" + +// ArchivedChannel snapshots a channel's pre-archive state so it can be +// restored by the unarchive command. PreviousOverwrites holds the channel's +// own permission overwrites as JSON ([]*discordgo.PermissionOverwrite). +type ArchivedChannel struct { + ChannelID string `json:"channel_id" gorm:"primaryKey"` + ChannelName string `json:"channel_name"` + PreviousParentID string `json:"previous_parent_id"` + PreviousOverwrites string `json:"previous_overwrites"` + ArchivedAt time.Time `json:"archived_at" gorm:"autoCreateTime"` +} + +func (ArchivedChannel) TableName() string { + return "archived_channel" +} diff --git a/discord/service/channel_archive.go b/discord/service/channel_archive.go new file mode 100644 index 0000000..7ba01b7 --- /dev/null +++ b/discord/service/channel_archive.go @@ -0,0 +1,163 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/gaucho-racing/sentinel/discord/config" + "github.com/gaucho-racing/sentinel/discord/database" + "github.com/gaucho-racing/sentinel/discord/model" + "github.com/gaucho-racing/sentinel/discord/pkg/logger" +) + +func getChannel(channelID string) (*discordgo.Channel, error) { + if ch, err := Discord.State.Channel(channelID); err == nil && ch != nil { + return ch, nil + } + return Discord.Channel(channelID) +} + +func IsArchiveCategory(channelID string) bool { + ch, err := getChannel(channelID) + if err != nil { + logger.SugarLogger.Errorf("channel archive: failed to get channel %s: %v", channelID, err) + return false + } + return ch.Type == discordgo.ChannelTypeGuildCategory && strings.EqualFold(ch.Name, config.DiscordArchiveCategoryName) +} + +func GetArchivedChannel(channelID string) (model.ArchivedChannel, error) { + var record model.ArchivedChannel + if err := database.DB.Where("channel_id = ?", channelID).First(&record).Error; err != nil { + return model.ArchivedChannel{}, err + } + return record, nil +} + +// ArchiveChannel handles a channel that was just moved into the archive +// category: it snapshots the channel's own permission overwrites and previous +// parent, syncs the archive category's overwrites onto the channel, and posts +// a notice listing the roles that can still see it. If a snapshot already +// exists (channel was archived before and dragged back in), the original +// snapshot is kept so a later unarchive restores the true pre-archive state. +func ArchiveChannel(channel *discordgo.Channel, previousParentID string) { + category, err := getChannel(channel.ParentID) + if err != nil { + logger.SugarLogger.Errorf("channel archive: failed to get archive category %s: %v", channel.ParentID, err) + return + } + + if _, err := GetArchivedChannel(channel.ID); err != nil { + overwrites, err := json.Marshal(channel.PermissionOverwrites) + if err != nil { + logger.SugarLogger.Errorf("channel archive: failed to marshal overwrites for %s (%s): %v", channel.ID, channel.Name, err) + return + } + record := model.ArchivedChannel{ + ChannelID: channel.ID, + ChannelName: channel.Name, + PreviousParentID: previousParentID, + PreviousOverwrites: string(overwrites), + } + if err := database.DB.Create(&record).Error; err != nil { + logger.SugarLogger.Errorf("channel archive: failed to persist snapshot for %s (%s): %v", channel.ID, channel.Name, err) + return + } + } else { + logger.SugarLogger.Infof("channel archive: snapshot already exists for %s (%s), keeping original", channel.ID, channel.Name) + } + + if len(category.PermissionOverwrites) > 0 { + _, err = Discord.ChannelEdit(channel.ID, &discordgo.ChannelEdit{ + PermissionOverwrites: category.PermissionOverwrites, + }) + if err != nil { + logger.SugarLogger.Errorf("channel archive: failed to sync category permissions onto %s (%s): %v", channel.ID, channel.Name, err) + return + } + } + logger.SugarLogger.Infof("channel archive: archived channel %s (%s)", channel.ID, channel.Name) + + content := fmt.Sprintf("This channel has been archived and is now only visible to %s. Run `%sunarchive` to restore it.", + strings.Join(viewerMentions(category.PermissionOverwrites), " "), config.DiscordPrefix) + sendMessageWithoutPings(channel.ID, content) +} + +// UnarchiveChannel moves an archived channel back to its previous category +// and restores its own permission overwrites from the snapshot. Returns the +// consumed snapshot so callers can report what was restored. +func UnarchiveChannel(channelID string) (model.ArchivedChannel, error) { + record, err := GetArchivedChannel(channelID) + if err != nil { + return model.ArchivedChannel{}, fmt.Errorf("channel %s is not archived", channelID) + } + + var overwrites []*discordgo.PermissionOverwrite + if record.PreviousOverwrites != "" { + if err := json.Unmarshal([]byte(record.PreviousOverwrites), &overwrites); err != nil { + return record, fmt.Errorf("failed to unmarshal overwrite snapshot: %w", err) + } + } + + // ChannelEdit's ParentID and PermissionOverwrites are omitempty, so a + // snapshot with no parent or no overwrites can't be expressed in a single + // edit: the parent is left as-is (caller surfaces this), and an empty + // overwrite set is restored by deleting the category-synced overwrites + // individually below. + edit := &discordgo.ChannelEdit{ParentID: record.PreviousParentID} + if len(overwrites) > 0 { + edit.PermissionOverwrites = overwrites + } + if _, err := Discord.ChannelEdit(channelID, edit); err != nil { + return record, fmt.Errorf("failed to restore channel: %w", err) + } + if len(overwrites) == 0 { + channel, err := Discord.Channel(channelID) + if err != nil { + return record, fmt.Errorf("failed to get channel for overwrite cleanup: %w", err) + } + for _, overwrite := range channel.PermissionOverwrites { + if err := Discord.ChannelPermissionDelete(channelID, overwrite.ID); err != nil { + logger.SugarLogger.Errorf("channel archive: failed to delete overwrite %s on %s: %v", overwrite.ID, channelID, err) + } + } + } + + if err := database.DB.Delete(&record).Error; err != nil { + logger.SugarLogger.Errorf("channel archive: failed to delete snapshot for %s: %v", channelID, err) + } + logger.SugarLogger.Infof("channel archive: unarchived channel %s (%s)", channelID, record.ChannelName) + return record, nil +} + +// viewerMentions returns mention strings for the roles and members granted +// VIEW_CHANNEL by the given overwrites. +func viewerMentions(overwrites []*discordgo.PermissionOverwrite) []string { + var mentions []string + for _, overwrite := range overwrites { + if overwrite.Allow&discordgo.PermissionViewChannel == 0 { + continue + } + switch overwrite.Type { + case discordgo.PermissionOverwriteTypeRole: + mentions = append(mentions, "<@&"+overwrite.ID+">") + case discordgo.PermissionOverwriteTypeMember: + mentions = append(mentions, "<@"+overwrite.ID+">") + } + } + return mentions +} + +// sendMessageWithoutPings posts a message whose role/user mentions render but +// don't notify anyone (zero-value AllowedMentions suppresses all pings). +func sendMessageWithoutPings(channelID, content string) { + _, err := Discord.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + Content: content, + AllowedMentions: &discordgo.MessageAllowedMentions{}, + }) + if err != nil { + logger.SugarLogger.Errorf("Failed to send message in %s: %v", channelID, err) + } +} From 13bc908033093ab9f854eac26aaece7d15ad9e24 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 8 Aug 2026 14:01:29 -0700 Subject: [PATCH 2/6] feat(discord): replace category-sync archiving with archive/unarchive commands --- discord/commands/archive.go | 23 ++++ discord/commands/handler.go | 28 +---- discord/commands/unarchive.go | 26 +++-- discord/config/config.go | 3 + discord/model/archived_channel.go | 1 + discord/service/channel_archive.go | 171 +++++++++++++++++++---------- 6 files changed, 157 insertions(+), 95 deletions(-) create mode 100644 discord/commands/archive.go diff --git a/discord/commands/archive.go b/discord/commands/archive.go new file mode 100644 index 0000000..3f652ee --- /dev/null +++ b/discord/commands/archive.go @@ -0,0 +1,23 @@ +package commands + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" + "github.com/gaucho-racing/sentinel/discord/pkg/logger" + "github.com/gaucho-racing/sentinel/discord/service" +) + +func Archive(args []string, s *discordgo.Session, m *discordgo.MessageCreate) { + if !requireManageChannels(s, m, "archive") { + return + } + if _, err := service.GetArchivedChannel(m.ChannelID); err == nil { + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> this channel is already archived.", m.Author.ID), archiveReplyTTL) + return + } + if err := service.ArchiveChannel(m.ChannelID, m.Author.ID); err != nil { + logger.SugarLogger.Errorf("archive: failed for channel %s: %v", m.ChannelID, err) + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> archiving failed — check the logs.", m.Author.ID), archiveReplyTTL) + } +} diff --git a/discord/commands/handler.go b/discord/commands/handler.go index bcbe5bc..4d09732 100644 --- a/discord/commands/handler.go +++ b/discord/commands/handler.go @@ -29,7 +29,6 @@ func InitializeBot() { service.Discord.AddHandler(OnGuildMemberRemove) service.Discord.AddHandler(OnUserUpdate) service.Discord.AddHandler(OnThreadUpdate) - service.Discord.AddHandler(OnChannelUpdate) service.Discord.Identify.Intents = discordgo.MakeIntent(discordgo.IntentsAll) err := service.Discord.Open() if err != nil { @@ -73,6 +72,8 @@ func OnDiscordMessage(s *discordgo.Session, m *discordgo.MessageCreate) { Ping(args, s, m) case "verify": Verify(args, s, m) + case "archive": + Archive(args, s, m) case "unarchive": Unarchive(args, s, m) default: @@ -188,31 +189,6 @@ func OnThreadUpdate(s *discordgo.Session, t *discordgo.ThreadUpdate) { service.KeepThreadAlive(t.Channel) } -// OnChannelUpdate archives channels that get moved into the archive category: -// permissions are synced to the category's and the prior state is snapshotted -// for the unarchive command. Only genuine moves are handled — the permission -// sync itself emits another ChannelUpdate with an unchanged parent, which the -// BeforeUpdate guard filters out (no loop). BeforeUpdate can be nil if the -// channel wasn't in the state cache; ArchiveChannel is idempotent so the -// worst case there is a re-sync and a duplicate notice. -func OnChannelUpdate(s *discordgo.Session, c *discordgo.ChannelUpdate) { - if c.GuildID != config.DiscordGuild || c.Type == discordgo.ChannelTypeGuildCategory { - return - } - if c.ParentID == "" || !service.IsArchiveCategory(c.ParentID) { - return - } - if c.BeforeUpdate != nil && c.BeforeUpdate.ParentID == c.ParentID { - return - } - previousParentID := "" - if c.BeforeUpdate != nil { - previousParentID = c.BeforeUpdate.ParentID - } - logger.SugarLogger.Infof("ChannelUpdate: channel %s (%s) was moved into the archive category", c.ID, c.Name) - service.ArchiveChannel(c.Channel, previousParentID) -} - func diffRoles(before, after []string) (added, removed []string) { beforeSet := make(map[string]struct{}, len(before)) for _, r := range before { diff --git a/discord/commands/unarchive.go b/discord/commands/unarchive.go index f55f7d6..dfa90ff 100644 --- a/discord/commands/unarchive.go +++ b/discord/commands/unarchive.go @@ -9,30 +9,40 @@ import ( "github.com/gaucho-racing/sentinel/discord/service" ) -const unarchiveReplyTTL = 10 * time.Second +const archiveReplyTTL = 10 * time.Second -func Unarchive(args []string, s *discordgo.Session, m *discordgo.MessageCreate) { +// requireManageChannels gates the archive commands behind the Manage +// Channels permission in the invoking channel, replying with a disappearing +// message when the check fails. +func requireManageChannels(s *discordgo.Session, m *discordgo.MessageCreate, command string) bool { permissions, err := s.UserChannelPermissions(m.Author.ID, m.ChannelID) if err != nil { - logger.SugarLogger.Errorf("unarchive: failed to compute permissions for %s in %s: %v", m.Author.ID, m.ChannelID, err) - service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> something went wrong, try again in a minute.", m.Author.ID), unarchiveReplyTTL) - return + logger.SugarLogger.Errorf("%s: failed to compute permissions for %s in %s: %v", command, m.Author.ID, m.ChannelID, err) + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> something went wrong, try again in a minute.", m.Author.ID), archiveReplyTTL) + return false } if permissions&discordgo.PermissionManageChannels == 0 { - service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> you need the Manage Channels permission to unarchive this channel.", m.Author.ID), unarchiveReplyTTL) + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> you need the Manage Channels permission to %s this channel.", m.Author.ID, command), archiveReplyTTL) + return false + } + return true +} + +func Unarchive(args []string, s *discordgo.Session, m *discordgo.MessageCreate) { + if !requireManageChannels(s, m, "unarchive") { return } record, err := service.UnarchiveChannel(m.ChannelID) if err != nil { logger.SugarLogger.Errorf("unarchive: failed for channel %s: %v", m.ChannelID, err) - service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> this channel isn't archived, or restoring it failed — check the logs.", m.Author.ID), unarchiveReplyTTL) + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> this channel isn't archived, or restoring it failed — check the logs.", m.Author.ID), archiveReplyTTL) return } content := "This channel has been unarchived and its permissions restored." if record.PreviousParentID == "" { - content += " I couldn't determine its original category, so it'll need to be moved manually." + content += " It wasn't in a category before it was archived, so it'll need to be moved out manually." } if _, err := s.ChannelMessageSend(m.ChannelID, content); err != nil { logger.SugarLogger.Errorf("unarchive: failed to send confirmation in %s: %v", m.ChannelID, err) diff --git a/discord/config/config.go b/discord/config/config.go index 395378e..136192a 100644 --- a/discord/config/config.go +++ b/discord/config/config.go @@ -77,6 +77,9 @@ const DiscordArchiveCategoryName = "ARCHIVE" var MembersDiscordRoleID = "820467859477889034" var AlumniDiscordRoleID = "817577502968512552" var GuestDiscordRoleID = "1511273081824477245" +var RobotDiscordRoleID = "1229611357259694132" +var SpecialAdvisorDiscordRoleID = "1386909324596609034" +var DevOpsDiscordRoleID = "1527194309915443271" var AeroSubteamDiscordRoleID = "761114473565519882" var BusinessSubteamDiscordRoleID = "761331962563919874" diff --git a/discord/model/archived_channel.go b/discord/model/archived_channel.go index 12f5c16..97397a9 100644 --- a/discord/model/archived_channel.go +++ b/discord/model/archived_channel.go @@ -10,6 +10,7 @@ type ArchivedChannel struct { ChannelName string `json:"channel_name"` PreviousParentID string `json:"previous_parent_id"` PreviousOverwrites string `json:"previous_overwrites"` + ArchivedBy string `json:"archived_by"` ArchivedAt time.Time `json:"archived_at" gorm:"autoCreateTime"` } diff --git a/discord/service/channel_archive.go b/discord/service/channel_archive.go index 7ba01b7..888d3c4 100644 --- a/discord/service/channel_archive.go +++ b/discord/service/channel_archive.go @@ -12,6 +12,25 @@ import ( "github.com/gaucho-racing/sentinel/discord/pkg/logger" ) +// archiveWriteMask covers the permissions stripped when a channel is +// archived: posting, threads, reactions, and voice connect. View and +// read-history bits are never touched, so an archived channel stays visible +// to exactly the audience that could see it before — just read-only. +const archiveWriteMask = discordgo.PermissionSendMessages | + discordgo.PermissionSendMessagesInThreads | + discordgo.PermissionCreatePublicThreads | + discordgo.PermissionCreatePrivateThreads | + discordgo.PermissionAddReactions | + discordgo.PermissionVoiceConnect + +// archiveExemptRoleIDs keep full access on archived channels. Roles with +// Administrator (Admin, Officer, etc.) bypass channel overwrites entirely +// and need no exemption here. +var archiveExemptRoleIDs = []string{ + config.RobotDiscordRoleID, + config.DevOpsDiscordRoleID, +} + func getChannel(channelID string) (*discordgo.Channel, error) { if ch, err := Discord.State.Channel(channelID); err == nil && ch != nil { return ch, nil @@ -19,13 +38,17 @@ func getChannel(channelID string) (*discordgo.Channel, error) { return Discord.Channel(channelID) } -func IsArchiveCategory(channelID string) bool { - ch, err := getChannel(channelID) +func FindArchiveCategory() (*discordgo.Channel, error) { + channels, err := GetGuildChannels() if err != nil { - logger.SugarLogger.Errorf("channel archive: failed to get channel %s: %v", channelID, err) - return false + return nil, err } - return ch.Type == discordgo.ChannelTypeGuildCategory && strings.EqualFold(ch.Name, config.DiscordArchiveCategoryName) + for _, ch := range channels { + if ch.Type == discordgo.ChannelTypeGuildCategory && strings.EqualFold(ch.Name, config.DiscordArchiveCategoryName) { + return ch, nil + } + } + return nil, fmt.Errorf("no category named %q in guild", config.DiscordArchiveCategoryName) } func GetArchivedChannel(channelID string) (model.ArchivedChannel, error) { @@ -36,58 +59,57 @@ func GetArchivedChannel(channelID string) (model.ArchivedChannel, error) { return record, nil } -// ArchiveChannel handles a channel that was just moved into the archive -// category: it snapshots the channel's own permission overwrites and previous -// parent, syncs the archive category's overwrites onto the channel, and posts -// a notice listing the roles that can still see it. If a snapshot already -// exists (channel was archived before and dragged back in), the original -// snapshot is kept so a later unarchive restores the true pre-archive state. -func ArchiveChannel(channel *discordgo.Channel, previousParentID string) { - category, err := getChannel(channel.ParentID) +// ArchiveChannel snapshots the channel's permission overwrites and parent +// category, moves it into the archive category, and rewrites its permissions +// to the standardized archived form (read-only for its existing audience, +// full access for the exempt roles). Posts a notice in the channel on +// success. +func ArchiveChannel(channelID, archivedBy string) error { + if _, err := GetArchivedChannel(channelID); err == nil { + return fmt.Errorf("channel %s is already archived", channelID) + } + channel, err := getChannel(channelID) if err != nil { - logger.SugarLogger.Errorf("channel archive: failed to get archive category %s: %v", channel.ParentID, err) - return + return fmt.Errorf("failed to get channel: %w", err) + } + category, err := FindArchiveCategory() + if err != nil { + return err } - if _, err := GetArchivedChannel(channel.ID); err != nil { - overwrites, err := json.Marshal(channel.PermissionOverwrites) - if err != nil { - logger.SugarLogger.Errorf("channel archive: failed to marshal overwrites for %s (%s): %v", channel.ID, channel.Name, err) - return - } - record := model.ArchivedChannel{ - ChannelID: channel.ID, - ChannelName: channel.Name, - PreviousParentID: previousParentID, - PreviousOverwrites: string(overwrites), - } - if err := database.DB.Create(&record).Error; err != nil { - logger.SugarLogger.Errorf("channel archive: failed to persist snapshot for %s (%s): %v", channel.ID, channel.Name, err) - return - } - } else { - logger.SugarLogger.Infof("channel archive: snapshot already exists for %s (%s), keeping original", channel.ID, channel.Name) + snapshot, err := json.Marshal(channel.PermissionOverwrites) + if err != nil { + return fmt.Errorf("failed to marshal overwrite snapshot: %w", err) + } + record := model.ArchivedChannel{ + ChannelID: channel.ID, + ChannelName: channel.Name, + PreviousParentID: channel.ParentID, + PreviousOverwrites: string(snapshot), + ArchivedBy: archivedBy, + } + if err := database.DB.Create(&record).Error; err != nil { + return fmt.Errorf("failed to persist snapshot: %w", err) } - if len(category.PermissionOverwrites) > 0 { - _, err = Discord.ChannelEdit(channel.ID, &discordgo.ChannelEdit{ - PermissionOverwrites: category.PermissionOverwrites, - }) - if err != nil { - logger.SugarLogger.Errorf("channel archive: failed to sync category permissions onto %s (%s): %v", channel.ID, channel.Name, err) - return - } + _, err = Discord.ChannelEdit(channel.ID, &discordgo.ChannelEdit{ + ParentID: category.ID, + PermissionOverwrites: archivedOverwrites(channel.PermissionOverwrites), + }) + if err != nil { + // Roll back the snapshot so a retry doesn't hit "already archived". + database.DB.Delete(&record) + return fmt.Errorf("failed to move and lock channel: %w", err) } - logger.SugarLogger.Infof("channel archive: archived channel %s (%s)", channel.ID, channel.Name) - content := fmt.Sprintf("This channel has been archived and is now only visible to %s. Run `%sunarchive` to restore it.", - strings.Join(viewerMentions(category.PermissionOverwrites), " "), config.DiscordPrefix) - sendMessageWithoutPings(channel.ID, content) + logger.SugarLogger.Infof("channel archive: archived channel %s (%s) by %s", channel.ID, channel.Name, archivedBy) + sendMessageWithoutPings(channel.ID, fmt.Sprintf("This channel has been archived by <@%s> and is now read-only. Run `%sunarchive` to restore it.", archivedBy, config.DiscordPrefix)) + return nil } // UnarchiveChannel moves an archived channel back to its previous category -// and restores its own permission overwrites from the snapshot. Returns the -// consumed snapshot so callers can report what was restored. +// and restores its snapshotted permission overwrites. Returns the consumed +// snapshot so callers can report what was restored. func UnarchiveChannel(channelID string) (model.ArchivedChannel, error) { record, err := GetArchivedChannel(channelID) if err != nil { @@ -104,7 +126,7 @@ func UnarchiveChannel(channelID string) (model.ArchivedChannel, error) { // ChannelEdit's ParentID and PermissionOverwrites are omitempty, so a // snapshot with no parent or no overwrites can't be expressed in a single // edit: the parent is left as-is (caller surfaces this), and an empty - // overwrite set is restored by deleting the category-synced overwrites + // overwrite set is restored by deleting the archive overwrites // individually below. edit := &discordgo.ChannelEdit{ParentID: record.PreviousParentID} if len(overwrites) > 0 { @@ -132,22 +154,49 @@ func UnarchiveChannel(channelID string) (model.ArchivedChannel, error) { return record, nil } -// viewerMentions returns mention strings for the roles and members granted -// VIEW_CHANNEL by the given overwrites. -func viewerMentions(overwrites []*discordgo.PermissionOverwrite) []string { - var mentions []string - for _, overwrite := range overwrites { - if overwrite.Allow&discordgo.PermissionViewChannel == 0 { - continue +// archivedOverwrites transforms a channel's overwrites into their archived +// form: every existing overwrite loses its write-bit allows, @everyone gets +// an explicit write deny (covering members whose write access comes from +// base permissions rather than an overwrite), and the exempt roles get view +// plus full write access back. +func archivedOverwrites(existing []*discordgo.PermissionOverwrite) []*discordgo.PermissionOverwrite { + overwrites := make([]*discordgo.PermissionOverwrite, 0, len(existing)+len(archiveExemptRoleIDs)+1) + index := make(map[string]*discordgo.PermissionOverwrite, len(existing)) + for _, overwrite := range existing { + copied := *overwrite + copied.Allow &^= archiveWriteMask + overwrites = append(overwrites, &copied) + index[copied.ID] = &copied + } + + if everyone, ok := index[config.DiscordGuild]; ok { + everyone.Deny |= archiveWriteMask + } else { + everyone = &discordgo.PermissionOverwrite{ + ID: config.DiscordGuild, + Type: discordgo.PermissionOverwriteTypeRole, + Deny: archiveWriteMask, } - switch overwrite.Type { - case discordgo.PermissionOverwriteTypeRole: - mentions = append(mentions, "<@&"+overwrite.ID+">") - case discordgo.PermissionOverwriteTypeMember: - mentions = append(mentions, "<@"+overwrite.ID+">") + overwrites = append(overwrites, everyone) + index[everyone.ID] = everyone + } + + exemptMask := int64(archiveWriteMask | discordgo.PermissionViewChannel) + for _, roleID := range archiveExemptRoleIDs { + if exempt, ok := index[roleID]; ok { + exempt.Allow |= exemptMask + exempt.Deny &^= exemptMask + } else { + exempt = &discordgo.PermissionOverwrite{ + ID: roleID, + Type: discordgo.PermissionOverwriteTypeRole, + Allow: exemptMask, + } + overwrites = append(overwrites, exempt) + index[exempt.ID] = exempt } } - return mentions + return overwrites } // sendMessageWithoutPings posts a message whose role/user mentions render but From 59eb6ce3cf6d820d7c21f626efaccab3908f8804 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 8 Aug 2026 14:11:56 -0700 Subject: [PATCH 3/6] feat(discord): auto-provision archive categories, store archiver entity ID, expose archived channels API --- discord/api/api.go | 1 + discord/api/channel_archive.go | 21 ++++++++ discord/model/archived_channel.go | 13 ++--- discord/service/channel_archive.go | 87 +++++++++++++++++++++++++----- 4 files changed, 103 insertions(+), 19 deletions(-) create mode 100644 discord/api/channel_archive.go diff --git a/discord/api/api.go b/discord/api/api.go index 4380908..0052e68 100644 --- a/discord/api/api.go +++ b/discord/api/api.go @@ -41,6 +41,7 @@ func InitializeRoutes(router *gin.Engine) { router.POST("/discord/onboarding-tokens/:id/consume", ConsumeOnboardingToken) router.GET("/discord/roles", GetRoles) router.GET("/discord/channels", GetChannels) + router.GET("/discord/archived-channels", GetArchivedChannels) router.GET("/discord/role-bindings", ListRoleBindings) router.POST("/discord/role-bindings", CreateRoleBinding) router.DELETE("/discord/role-bindings/:bindingID", DeleteRoleBinding) diff --git a/discord/api/channel_archive.go b/discord/api/channel_archive.go new file mode 100644 index 0000000..f479ddf --- /dev/null +++ b/discord/api/channel_archive.go @@ -0,0 +1,21 @@ +package api + +import ( + "net/http" + + "github.com/gaucho-racing/sentinel/discord/pkg/logger" + "github.com/gaucho-racing/sentinel/discord/service" + "github.com/gin-gonic/gin" +) + +func GetArchivedChannels(c *gin.Context) { + Require(c, RequestTokenHasScope(c, "sentinel:all")) + + records, err := service.GetAllArchivedChannels() + if err != nil { + logger.SugarLogger.Errorf("Failed to fetch archived channels: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch archived channels"}) + return + } + c.JSON(http.StatusOK, records) +} diff --git a/discord/model/archived_channel.go b/discord/model/archived_channel.go index 97397a9..316623c 100644 --- a/discord/model/archived_channel.go +++ b/discord/model/archived_channel.go @@ -6,12 +6,13 @@ import "time" // restored by the unarchive command. PreviousOverwrites holds the channel's // own permission overwrites as JSON ([]*discordgo.PermissionOverwrite). type ArchivedChannel struct { - ChannelID string `json:"channel_id" gorm:"primaryKey"` - ChannelName string `json:"channel_name"` - PreviousParentID string `json:"previous_parent_id"` - PreviousOverwrites string `json:"previous_overwrites"` - ArchivedBy string `json:"archived_by"` - ArchivedAt time.Time `json:"archived_at" gorm:"autoCreateTime"` + ChannelID string `json:"channel_id" gorm:"primaryKey"` + ChannelName string `json:"channel_name"` + PreviousParentID string `json:"previous_parent_id"` + PreviousOverwrites string `json:"previous_overwrites"` + ArchivedByEntityID string `json:"archived_by_entity_id"` + ArchivedByDiscordID string `json:"archived_by_discord_id"` + ArchivedAt time.Time `json:"archived_at" gorm:"autoCreateTime"` } func (ArchivedChannel) TableName() string { diff --git a/discord/service/channel_archive.go b/discord/service/channel_archive.go index 888d3c4..9a3fa9b 100644 --- a/discord/service/channel_archive.go +++ b/discord/service/channel_archive.go @@ -38,17 +38,69 @@ func getChannel(channelID string) (*discordgo.Channel, error) { return Discord.Channel(channelID) } -func FindArchiveCategory() (*discordgo.Channel, error) { +// discordCategoryChannelCap is Discord's hard limit on channels per category. +const discordCategoryChannelCap = 50 + +// findOrCreateArchiveCategory returns an archive category with room for one +// more channel. Discord allows duplicate category names, so when every +// existing ARCHIVE category is at the cap (or none exists) a new one is +// provisioned at the bottom of the channel list, cloning permission +// overwrites from the last existing archive category when there is one. +func findOrCreateArchiveCategory() (*discordgo.Channel, error) { channels, err := GetGuildChannels() if err != nil { return nil, err } + var archiveCategories []*discordgo.Channel + childCounts := make(map[string]int) for _, ch := range channels { - if ch.Type == discordgo.ChannelTypeGuildCategory && strings.EqualFold(ch.Name, config.DiscordArchiveCategoryName) { - return ch, nil + if ch.Type == discordgo.ChannelTypeGuildCategory { + if strings.EqualFold(ch.Name, config.DiscordArchiveCategoryName) { + archiveCategories = append(archiveCategories, ch) + } + } else if ch.ParentID != "" { + childCounts[ch.ParentID]++ } } - return nil, fmt.Errorf("no category named %q in guild", config.DiscordArchiveCategoryName) + for _, category := range archiveCategories { + if childCounts[category.ID] < discordCategoryChannelCap { + return category, nil + } + } + + data := discordgo.GuildChannelCreateData{ + Name: config.DiscordArchiveCategoryName, + Type: discordgo.ChannelTypeGuildCategory, + } + if len(archiveCategories) > 0 { + data.PermissionOverwrites = archiveCategories[len(archiveCategories)-1].PermissionOverwrites + } else { + data.PermissionOverwrites = defaultArchiveCategoryOverwrites() + } + category, err := Discord.GuildChannelCreateComplex(config.DiscordGuild, data) + if err != nil { + return nil, fmt.Errorf("failed to provision new archive category: %w", err) + } + logger.SugarLogger.Infof("channel archive: provisioned new archive category %s (existing ones full: %d)", category.ID, len(archiveCategories)) + return category, nil +} + +// defaultArchiveCategoryOverwrites is only used when provisioning the very +// first archive category: hidden from @everyone, visible to the exempt roles. +func defaultArchiveCategoryOverwrites() []*discordgo.PermissionOverwrite { + overwrites := []*discordgo.PermissionOverwrite{{ + ID: config.DiscordGuild, + Type: discordgo.PermissionOverwriteTypeRole, + Deny: discordgo.PermissionViewChannel, + }} + for _, roleID := range archiveExemptRoleIDs { + overwrites = append(overwrites, &discordgo.PermissionOverwrite{ + ID: roleID, + Type: discordgo.PermissionOverwriteTypeRole, + Allow: discordgo.PermissionViewChannel, + }) + } + return overwrites } func GetArchivedChannel(channelID string) (model.ArchivedChannel, error) { @@ -59,12 +111,20 @@ func GetArchivedChannel(channelID string) (model.ArchivedChannel, error) { return record, nil } +func GetAllArchivedChannels() ([]model.ArchivedChannel, error) { + var records []model.ArchivedChannel + if err := database.DB.Order("archived_at desc").Find(&records).Error; err != nil { + return []model.ArchivedChannel{}, err + } + return records, nil +} + // ArchiveChannel snapshots the channel's permission overwrites and parent // category, moves it into the archive category, and rewrites its permissions // to the standardized archived form (read-only for its existing audience, // full access for the exempt roles). Posts a notice in the channel on // success. -func ArchiveChannel(channelID, archivedBy string) error { +func ArchiveChannel(channelID, archivedByDiscordID string) error { if _, err := GetArchivedChannel(channelID); err == nil { return fmt.Errorf("channel %s is already archived", channelID) } @@ -72,7 +132,7 @@ func ArchiveChannel(channelID, archivedBy string) error { if err != nil { return fmt.Errorf("failed to get channel: %w", err) } - category, err := FindArchiveCategory() + category, err := findOrCreateArchiveCategory() if err != nil { return err } @@ -82,11 +142,12 @@ func ArchiveChannel(channelID, archivedBy string) error { return fmt.Errorf("failed to marshal overwrite snapshot: %w", err) } record := model.ArchivedChannel{ - ChannelID: channel.ID, - ChannelName: channel.Name, - PreviousParentID: channel.ParentID, - PreviousOverwrites: string(snapshot), - ArchivedBy: archivedBy, + ChannelID: channel.ID, + ChannelName: channel.Name, + PreviousParentID: channel.ParentID, + PreviousOverwrites: string(snapshot), + ArchivedByEntityID: GetEntityIDForDiscordUser(archivedByDiscordID), + ArchivedByDiscordID: archivedByDiscordID, } if err := database.DB.Create(&record).Error; err != nil { return fmt.Errorf("failed to persist snapshot: %w", err) @@ -102,8 +163,8 @@ func ArchiveChannel(channelID, archivedBy string) error { return fmt.Errorf("failed to move and lock channel: %w", err) } - logger.SugarLogger.Infof("channel archive: archived channel %s (%s) by %s", channel.ID, channel.Name, archivedBy) - sendMessageWithoutPings(channel.ID, fmt.Sprintf("This channel has been archived by <@%s> and is now read-only. Run `%sunarchive` to restore it.", archivedBy, config.DiscordPrefix)) + logger.SugarLogger.Infof("channel archive: archived channel %s (%s) by %s", channel.ID, channel.Name, archivedByDiscordID) + sendMessageWithoutPings(channel.ID, fmt.Sprintf("This channel has been archived by <@%s> and is now read-only. Run `%sunarchive` to restore it.", archivedByDiscordID, config.DiscordPrefix)) return nil } From b2a44d24c77fdbe698a5bb108d8befd3ba0f57af Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 8 Aug 2026 14:21:24 -0700 Subject: [PATCH 4/6] feat(discord): gate archive commands by sentinel group membership --- discord/commands/archive.go | 2 +- discord/commands/unarchive.go | 31 +++++++++++++++++++------------ discord/config/config.go | 4 ++++ discord/service/entity.go | 27 +++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/discord/commands/archive.go b/discord/commands/archive.go index 3f652ee..5663d72 100644 --- a/discord/commands/archive.go +++ b/discord/commands/archive.go @@ -9,7 +9,7 @@ import ( ) func Archive(args []string, s *discordgo.Session, m *discordgo.MessageCreate) { - if !requireManageChannels(s, m, "archive") { + if !requireArchiveAccess(s, m, "archive") { return } if _, err := service.GetArchivedChannel(m.ChannelID); err == nil { diff --git a/discord/commands/unarchive.go b/discord/commands/unarchive.go index dfa90ff..6b4ae40 100644 --- a/discord/commands/unarchive.go +++ b/discord/commands/unarchive.go @@ -2,34 +2,41 @@ package commands import ( "fmt" + "strings" "time" "github.com/bwmarrin/discordgo" + "github.com/gaucho-racing/sentinel/discord/config" "github.com/gaucho-racing/sentinel/discord/pkg/logger" "github.com/gaucho-racing/sentinel/discord/service" ) const archiveReplyTTL = 10 * time.Second -// requireManageChannels gates the archive commands behind the Manage -// Channels permission in the invoking channel, replying with a disappearing -// message when the check fails. -func requireManageChannels(s *discordgo.Session, m *discordgo.MessageCreate, command string) bool { - permissions, err := s.UserChannelPermissions(m.Author.ID, m.ChannelID) +// requireArchiveAccess gates the archive commands to members of the allowed +// Sentinel groups, replying with a disappearing message when the check +// fails. Fails closed: a missing entity link or a core lookup failure both +// deny access. +func requireArchiveAccess(s *discordgo.Session, m *discordgo.MessageCreate, command string) bool { + groupNames, err := service.GetGroupNamesForDiscordUser(m.Author.ID) if err != nil { - logger.SugarLogger.Errorf("%s: failed to compute permissions for %s in %s: %v", command, m.Author.ID, m.ChannelID, err) - service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> something went wrong, try again in a minute.", m.Author.ID), archiveReplyTTL) + logger.SugarLogger.Errorf("%s: failed to fetch sentinel groups for %s: %v", command, m.Author.ID, err) + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> you don't have permission to %s channels.", m.Author.ID, command), archiveReplyTTL) return false } - if permissions&discordgo.PermissionManageChannels == 0 { - service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> you need the Manage Channels permission to %s this channel.", m.Author.ID, command), archiveReplyTTL) - return false + for _, name := range groupNames { + for _, allowed := range config.ArchiveCommandAllowedGroups { + if strings.EqualFold(name, allowed) { + return true + } + } } - return true + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> you don't have permission to %s channels.", m.Author.ID, command), archiveReplyTTL) + return false } func Unarchive(args []string, s *discordgo.Session, m *discordgo.MessageCreate) { - if !requireManageChannels(s, m, "unarchive") { + if !requireArchiveAccess(s, m, "unarchive") { return } diff --git a/discord/config/config.go b/discord/config/config.go index 136192a..e9a5a37 100644 --- a/discord/config/config.go +++ b/discord/config/config.go @@ -74,6 +74,10 @@ func IsProduction() bool { // case-insensitive) that channels get moved into to archive them. const DiscordArchiveCategoryName = "ARCHIVE" +// ArchiveCommandAllowedGroups are the Sentinel groups (matched by name, +// case-insensitive) whose members may run the archive/unarchive commands. +var ArchiveCommandAllowedGroups = []string{"Admins", "Leads", "Officers"} + var MembersDiscordRoleID = "820467859477889034" var AlumniDiscordRoleID = "817577502968512552" var GuestDiscordRoleID = "1511273081824477245" diff --git a/discord/service/entity.go b/discord/service/entity.go index 3533b31..e3de15f 100644 --- a/discord/service/entity.go +++ b/discord/service/entity.go @@ -1,6 +1,8 @@ package service import ( + "fmt" + "github.com/gaucho-racing/sentinel/discord/pkg/logger" "github.com/gaucho-racing/sentinel/discord/pkg/sentinel" ) @@ -40,6 +42,31 @@ func GetEntityEmailForDiscordUser(discordUserID string) string { return entity.EmailAuth.Email } +type groupResponse struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// GetGroupNamesForDiscordUser returns the names of the Sentinel groups the +// Discord user's linked entity belongs to. Returns an error when the user +// has no linked entity or the core lookup fails, so authorization callers +// can fail closed. +func GetGroupNamesForDiscordUser(discordUserID string) ([]string, error) { + entityID := GetEntityIDForDiscordUser(discordUserID) + if entityID == "" { + return nil, fmt.Errorf("no sentinel entity linked to discord user %s", discordUserID) + } + var groups []groupResponse + if err := sentinel.Get("/api/core/entity/"+entityID+"/groups", &groups); err != nil { + return nil, err + } + names := make([]string, 0, len(groups)) + for _, group := range groups { + names = append(names, group.Name) + } + return names, nil +} + // SyncDiscordUserAvatar mirrors a Discord user's avatar onto the linked // Sentinel user, when one exists. No-ops silently when the Discord user // has no Sentinel record or the avatar is already current. From f3152a2b22aea73756dde6f3e44af8162007136f Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 8 Aug 2026 14:26:32 -0700 Subject: [PATCH 5/6] refactor(discord): centralize group-based command gate in handler --- discord/commands/archive.go | 7 ++++--- discord/commands/handler.go | 25 +++++++++++++++++++++++++ discord/commands/unarchive.go | 32 +++----------------------------- discord/config/config.go | 4 ---- 4 files changed, 32 insertions(+), 36 deletions(-) diff --git a/discord/commands/archive.go b/discord/commands/archive.go index 5663d72..0ad5c67 100644 --- a/discord/commands/archive.go +++ b/discord/commands/archive.go @@ -9,15 +9,16 @@ import ( ) func Archive(args []string, s *discordgo.Session, m *discordgo.MessageCreate) { - if !requireArchiveAccess(s, m, "archive") { + allowedGroups := []string{"Admins", "Leads", "Officers"} + if !requireGroupMembership(m, "archive", allowedGroups) { return } if _, err := service.GetArchivedChannel(m.ChannelID); err == nil { - service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> this channel is already archived.", m.Author.ID), archiveReplyTTL) + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> this channel is already archived.", m.Author.ID), commandReplyTTL) return } if err := service.ArchiveChannel(m.ChannelID, m.Author.ID); err != nil { logger.SugarLogger.Errorf("archive: failed for channel %s: %v", m.ChannelID, err) - service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> archiving failed — check the logs.", m.Author.ID), archiveReplyTTL) + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> archiving failed — check the logs.", m.Author.ID), commandReplyTTL) } } diff --git a/discord/commands/handler.go b/discord/commands/handler.go index 4d09732..3368269 100644 --- a/discord/commands/handler.go +++ b/discord/commands/handler.go @@ -1,8 +1,10 @@ package commands import ( + "fmt" "strings" "sync" + "time" "github.com/bwmarrin/discordgo" "github.com/gaucho-racing/sentinel/discord/config" @@ -11,6 +13,29 @@ import ( "github.com/gaucho-racing/sentinel/discord/service" ) +const commandReplyTTL = 10 * time.Second + +// requireGroupMembership gates a command to members of the given Sentinel +// groups (matched by name, case-insensitive), replying with a disappearing +// message when the check fails. Fails closed: a missing entity link or a +// core lookup failure both deny access. +func requireGroupMembership(m *discordgo.MessageCreate, command string, allowedGroups []string) bool { + groupNames, err := service.GetGroupNamesForDiscordUser(m.Author.ID) + if err != nil { + logger.SugarLogger.Errorf("%s: failed to fetch sentinel groups for %s: %v", command, m.Author.ID, err) + } else { + for _, name := range groupNames { + for _, allowed := range allowedGroups { + if strings.EqualFold(name, allowed) { + return true + } + } + } + } + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> you don't have permission to use the `%s%s` command.", m.Author.ID, config.DiscordPrefix, command), commandReplyTTL) + return false +} + // readyOnce guards the startup sweep so a gateway reconnect (which also // fires Ready) doesn't repeatedly kick the sweep. Subsequent reconnects // are covered by the periodic cron + per-user event reconciles anyway. diff --git a/discord/commands/unarchive.go b/discord/commands/unarchive.go index 6b4ae40..67e798a 100644 --- a/discord/commands/unarchive.go +++ b/discord/commands/unarchive.go @@ -2,48 +2,22 @@ package commands import ( "fmt" - "strings" - "time" "github.com/bwmarrin/discordgo" - "github.com/gaucho-racing/sentinel/discord/config" "github.com/gaucho-racing/sentinel/discord/pkg/logger" "github.com/gaucho-racing/sentinel/discord/service" ) -const archiveReplyTTL = 10 * time.Second - -// requireArchiveAccess gates the archive commands to members of the allowed -// Sentinel groups, replying with a disappearing message when the check -// fails. Fails closed: a missing entity link or a core lookup failure both -// deny access. -func requireArchiveAccess(s *discordgo.Session, m *discordgo.MessageCreate, command string) bool { - groupNames, err := service.GetGroupNamesForDiscordUser(m.Author.ID) - if err != nil { - logger.SugarLogger.Errorf("%s: failed to fetch sentinel groups for %s: %v", command, m.Author.ID, err) - service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> you don't have permission to %s channels.", m.Author.ID, command), archiveReplyTTL) - return false - } - for _, name := range groupNames { - for _, allowed := range config.ArchiveCommandAllowedGroups { - if strings.EqualFold(name, allowed) { - return true - } - } - } - service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> you don't have permission to %s channels.", m.Author.ID, command), archiveReplyTTL) - return false -} - func Unarchive(args []string, s *discordgo.Session, m *discordgo.MessageCreate) { - if !requireArchiveAccess(s, m, "unarchive") { + allowedGroups := []string{"Admins", "Leads", "Officers"} + if !requireGroupMembership(m, "unarchive", allowedGroups) { return } record, err := service.UnarchiveChannel(m.ChannelID) if err != nil { logger.SugarLogger.Errorf("unarchive: failed for channel %s: %v", m.ChannelID, err) - service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> this channel isn't archived, or restoring it failed — check the logs.", m.Author.ID), archiveReplyTTL) + service.SendDisappearingMessage(m.ChannelID, fmt.Sprintf("<@%s> this channel isn't archived, or restoring it failed — check the logs.", m.Author.ID), commandReplyTTL) return } diff --git a/discord/config/config.go b/discord/config/config.go index e9a5a37..136192a 100644 --- a/discord/config/config.go +++ b/discord/config/config.go @@ -74,10 +74,6 @@ func IsProduction() bool { // case-insensitive) that channels get moved into to archive them. const DiscordArchiveCategoryName = "ARCHIVE" -// ArchiveCommandAllowedGroups are the Sentinel groups (matched by name, -// case-insensitive) whose members may run the archive/unarchive commands. -var ArchiveCommandAllowedGroups = []string{"Admins", "Leads", "Officers"} - var MembersDiscordRoleID = "820467859477889034" var AlumniDiscordRoleID = "817577502968512552" var GuestDiscordRoleID = "1511273081824477245" From d6b03f5c55829d70567105208a6697e17f269825 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 8 Aug 2026 14:41:41 -0700 Subject: [PATCH 6/6] fix(discord): keep unarchive snapshot when overwrite cleanup fails --- discord/service/channel_archive.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/discord/service/channel_archive.go b/discord/service/channel_archive.go index 9a3fa9b..230927c 100644 --- a/discord/service/channel_archive.go +++ b/discord/service/channel_archive.go @@ -201,9 +201,12 @@ func UnarchiveChannel(channelID string) (model.ArchivedChannel, error) { if err != nil { return record, fmt.Errorf("failed to get channel for overwrite cleanup: %w", err) } + // Keep the snapshot if any deletion fails so the command can be + // retried — the restore is idempotent, a retry just re-applies the + // parent edit and deletes the remaining archive overwrites. for _, overwrite := range channel.PermissionOverwrites { if err := Discord.ChannelPermissionDelete(channelID, overwrite.ID); err != nil { - logger.SugarLogger.Errorf("channel archive: failed to delete overwrite %s on %s: %v", overwrite.ID, channelID, err) + return record, fmt.Errorf("failed to delete overwrite %s: %w", overwrite.ID, err) } } }