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/commands/archive.go b/discord/commands/archive.go new file mode 100644 index 0000000..0ad5c67 --- /dev/null +++ b/discord/commands/archive.go @@ -0,0 +1,24 @@ +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) { + 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), 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), commandReplyTTL) + } +} diff --git a/discord/commands/handler.go b/discord/commands/handler.go index 751865d..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. @@ -72,6 +97,10 @@ 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: logger.SugarLogger.Infof("Unknown command: %s", command) } diff --git a/discord/commands/unarchive.go b/discord/commands/unarchive.go new file mode 100644 index 0000000..67e798a --- /dev/null +++ b/discord/commands/unarchive.go @@ -0,0 +1,31 @@ +package commands + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" + "github.com/gaucho-racing/sentinel/discord/pkg/logger" + "github.com/gaucho-racing/sentinel/discord/service" +) + +func Unarchive(args []string, s *discordgo.Session, m *discordgo.MessageCreate) { + 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), commandReplyTTL) + return + } + + content := "This channel has been unarchived and its permissions restored." + if record.PreviousParentID == "" { + 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 549c3a5..136192a 100644 --- a/discord/config/config.go +++ b/discord/config/config.go @@ -70,9 +70,16 @@ 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" +var RobotDiscordRoleID = "1229611357259694132" +var SpecialAdvisorDiscordRoleID = "1386909324596609034" +var DevOpsDiscordRoleID = "1527194309915443271" var AeroSubteamDiscordRoleID = "761114473565519882" var BusinessSubteamDiscordRoleID = "761331962563919874" 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..316623c --- /dev/null +++ b/discord/model/archived_channel.go @@ -0,0 +1,20 @@ +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"` + 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 { + return "archived_channel" +} diff --git a/discord/service/channel_archive.go b/discord/service/channel_archive.go new file mode 100644 index 0000000..230927c --- /dev/null +++ b/discord/service/channel_archive.go @@ -0,0 +1,276 @@ +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" +) + +// 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 + } + return Discord.Channel(channelID) +} + +// 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 { + if strings.EqualFold(ch.Name, config.DiscordArchiveCategoryName) { + archiveCategories = append(archiveCategories, ch) + } + } else if ch.ParentID != "" { + childCounts[ch.ParentID]++ + } + } + 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) { + var record model.ArchivedChannel + if err := database.DB.Where("channel_id = ?", channelID).First(&record).Error; err != nil { + return model.ArchivedChannel{}, err + } + 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, archivedByDiscordID string) error { + if _, err := GetArchivedChannel(channelID); err == nil { + return fmt.Errorf("channel %s is already archived", channelID) + } + channel, err := getChannel(channelID) + if err != nil { + return fmt.Errorf("failed to get channel: %w", err) + } + category, err := findOrCreateArchiveCategory() + if err != nil { + return err + } + + 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), + ArchivedByEntityID: GetEntityIDForDiscordUser(archivedByDiscordID), + ArchivedByDiscordID: archivedByDiscordID, + } + if err := database.DB.Create(&record).Error; err != nil { + return fmt.Errorf("failed to persist snapshot: %w", err) + } + + _, 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) 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 +} + +// UnarchiveChannel moves an archived channel back to its previous category +// 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 { + 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 archive 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) + } + // 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 { + return record, fmt.Errorf("failed to delete overwrite %s: %w", overwrite.ID, 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 +} + +// 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, + } + 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 overwrites +} + +// 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) + } +} 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.