From 430c0afaa6edee94393948a0f3014669566f5e3c Mon Sep 17 00:00:00 2001 From: Ava Affine Date: Wed, 8 Jan 2025 13:40:11 -0800 Subject: [PATCH] Confessions module This commit adds a confessions feature that allows users to mark a "confessional" channel and also to post anonymously to it. The changes that this comprises of are as follows: - New discord "slash" commands for both marking a confessional and posting to it - a bunch of stuff in the discord module to register and deregister "slash" commands - New event type to track marked confessionals - confession module that processes new confession channel links and also posts confessions to corresponding confessionals Not included in this commit: - a way to cleanup obsolete or reconfigured confession channel links - access control for the confessional slash commands Signed-off-by: Ava Affine --- internal/confession/confession.go | 58 ++++++++++++++++ internal/discord/commands.go | 112 ++++++++++++++++++++++++++++++ internal/discord/discord.go | 3 + internal/discord/handlers.go | 1 + internal/state/events.go | 44 ++++++++++++ internal/state/state.go | 35 ++++++++++ 6 files changed, 253 insertions(+) create mode 100644 internal/confession/confession.go create mode 100644 internal/discord/commands.go diff --git a/internal/confession/confession.go b/internal/confession/confession.go new file mode 100644 index 0000000..2681d31 --- /dev/null +++ b/internal/confession/confession.go @@ -0,0 +1,58 @@ +package confession + +import ( + "errors" + "sync" + + "github.com/bwmarrin/discordgo" + "gitlab.com/whom/bingobot/internal/logging" + "gitlab.com/whom/bingobot/internal/state" +) + +/* Activity module + * This module posts anonymous confessions according to a linked channel map + */ + +const ( + ActivityModuleStartFail = "failed to start activity module" +) + +var ( + // guild ID to channel ID + linkLock sync.RWMutex + confessionChannelLinks map[string]state.ConfessionsChannelLinkEvent +) + +func Start() error { + ch, err := state.ConfessionsChannelLink.Subscribe() + if err != nil { + return errors.Join( + errors.New(ActivityModuleStartFail), + err, + ) + } + + // process incoming events loop + go func() { + for { + ev := <- ch + e := ev.(state.ConfessionsChannelLinkEvent) + linkLock.Lock() + confessionChannelLinks[e.GuildID] = e + linkLock.Unlock() + } + }() + + return nil +} + +func MakeConfession(s *discordgo.Session, guildID string, content string) { + linkLock.RLock() + link, ok := confessionChannelLinks[guildID] + linkLock.RUnlock() + if !ok { + logging.Error("Failed to send confession in guild %s: no link exists in map", guildID) + return + } + s.ChannelMessageSend(link.ChannelID, content) +} diff --git a/internal/discord/commands.go b/internal/discord/commands.go new file mode 100644 index 0000000..7a13ed0 --- /dev/null +++ b/internal/discord/commands.go @@ -0,0 +1,112 @@ +package discord + +import ( + "time" + + "github.com/bwmarrin/discordgo" + + "gitlab.com/whom/bingobot/internal/confession" + "gitlab.com/whom/bingobot/internal/logging" + "gitlab.com/whom/bingobot/internal/state" +) + +var ( + // map of guildID to registeredCommands + registeredCommands map[string][]*discordgo.ApplicationCommand + + // all commands + commandList = []*discordgo.ApplicationCommand{ + // TODO: Limit usage somehow? + // maybe delete this and use the vote module instead + &discordgo.ApplicationCommand{ + Name: "confessional", + Description: "mark a channel as a designated confessional for a guild", + }, + + &discordgo.ApplicationCommand{ + Name: "confess", + Description: "anonymously post a confession in configured channel", + Options: []*discordgo.ApplicationCommandOption{ + &discordgo.ApplicationCommandOption{ + Name: "confession", + Description: "A confession to be posted anonymously", + Required: true, + }, + }, + }, + } + + commandHandlers = map[string]func( + s *discordgo.Session, + i *discordgo.InteractionCreate, + ) { + "confessional": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + state.PublishEvent(state.ConfessionsChannelLinkEvent{ + GuildID: i.GuildID, + ChannelID: i.ChannelID, + Created: time.Now(), + }) + }, + + // handle a confession + "confess": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + for _, v := range i.ApplicationCommandData().Options { + if v.Name == "confession" { + confession.MakeConfession(s, i.GuildID, v.StringValue()) + } + } + }, + } +) + +func handleCommand(s *discordgo.Session, e *discordgo.InteractionCreate) { + name := e.ApplicationCommandData().Name + // TODO: audit log + if h, ok := commandHandlers[name]; ok { + h(s, e) + } else { + logging.Debug("no handler for command: %s", name) + } +} + +func registerCommands(s *discordgo.Session) { + for _, guild := range s.State.Guilds { + cmds, err := s.ApplicationCommandBulkOverwrite( + s.State.Application.ID, + guild.ID, + commandList, + ) + + if err != nil { + logging.Error( + "Failed to register commands for guild %s: %s", + guild.ID, err.Error(), + ) + } else { + logging.Info("Registered commands for guild %s", guild.ID) + registeredCommands[guild.ID] = cmds + } + } +} + +func deregisterCommands(s *discordgo.Session) { + for guild, commands := range registeredCommands { + for _, cmd := range commands { + if err := s.ApplicationCommandDelete( + s.State.Application.ID, + guild, + cmd.ID, + ); err != nil { + logging.Error( + "Failed to delete %s command (id: %s) from guild %s", + cmd.Name, cmd.ID, guild, + ) + } else { + logging.Info( + "Deregistered command %s (id: %s) from guild %s", + cmd.Name, cmd.ID, guild, + ) + } + } + } +} diff --git a/internal/discord/discord.go b/internal/discord/discord.go index 896ba1a..6c81929 100644 --- a/internal/discord/discord.go +++ b/internal/discord/discord.go @@ -36,10 +36,13 @@ func Connect(token string) error { return fmt.Errorf("failed to open discord session: %s", err) } + registerCommands(session.s) + return nil } func Close() { + deregisterCommands(session.s) err := session.s.Close() if err != nil { diff --git a/internal/discord/handlers.go b/internal/discord/handlers.go index c01d441..03b5434 100644 --- a/internal/discord/handlers.go +++ b/internal/discord/handlers.go @@ -9,6 +9,7 @@ func addHandlers() { session.s.AddHandler(handleConnect) session.s.AddHandler(handleDisconnect) session.s.AddHandler(handleVoiceStateUpdate) + session.s.AddHandler(handleCommand) // handles InteractionCreate } func handleConnect(s *discordgo.Session, e *discordgo.Connect) { diff --git a/internal/state/events.go b/internal/state/events.go index c14ee77..285db51 100644 --- a/internal/state/events.go +++ b/internal/state/events.go @@ -226,3 +226,47 @@ func (te TestEvent) Validate() error { func (te TestEvent) Disposable() bool { return te.Dispose } + +const ( + ConfessionsLinkEventGuildKey = "guild_id" + ConfessionsLinkEventChannelKey = "channel_id" + ConfessionsLinkEventCreatedKey = "created" + ConfessionsLinkEventObsoleteKey = "obsolete" + + BadConfessionsLinkEventError = "link event doesnt have required fields" +) + +type ConfessionsChannelLinkEvent struct { + GuildID string + ChannelID string + Created time.Time +} + +func (e ConfessionsChannelLinkEvent) Type() EventType { + return ConfessionsChannelLink +} + +func (e ConfessionsChannelLinkEvent) Time() time.Time { + return e.Time() +} + +func (e ConfessionsChannelLinkEvent) Data() map[string]string { + return map[string]string{ + ConfessionsLinkEventGuildKey: e.GuildID, + ConfessionsLinkEventChannelKey: e.ChannelID, + ConfessionsLinkEventCreatedKey: e.Created.Format(time.RFC3339), + } +} + +func (e ConfessionsChannelLinkEvent) Validate() error { + if len(e.ChannelID) > 1 || len(e.GuildID) > 1 { + return errors.New(BadConfessionsLinkEventError) + } + + return nil +} + +func (e ConfessionsChannelLinkEvent) Disposable() bool { + // TODO + return false +} diff --git a/internal/state/state.go b/internal/state/state.go index f4a83fd..9b94ec2 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -183,6 +183,7 @@ const ( Restoration UserActive Test + ConfessionsChannelLink // ... // leave this last @@ -202,6 +203,8 @@ func EventTypeFromString(doc string) EventType { return UserActive case "Test": return Test + case "ConfessionsChannelLink": + return ConfessionsChannelLink default: // error case return NumEventTypes @@ -215,6 +218,7 @@ func (et EventType) String() string { "Restoration", "UserActive", "Test", + "ConfessionsChannelLink", } if et < 0 || et >= NumEventTypes { @@ -266,24 +270,28 @@ func (et EventType) MakeEvent(data map[string]string) (Event, error) { switch et { case Vote: return VoteEvent(data), nil + case Challenge: e, err := MakeUserEvent(data) if err != nil { return nil, errors.Join(errors.New(BadChallengeEvent), err) } return ChallengeEvent{*e}, nil + case Restoration: e, err := MakeUserEvent(data) if err != nil { return nil, errors.Join(errors.New(BadRestorationEvent), err) } return RestorationEvent{*e}, nil + case UserActive: e, err := MakeUserEvent(data) if err != nil { return nil, errors.Join(errors.New(BadUserActiveEvent), err) } return UserActiveEvent{*e}, nil + case Test: disp := false if v, ok := data[TestEventDisposeKey]; ok && v == "t" { @@ -300,6 +308,33 @@ func (et EventType) MakeEvent(data map[string]string) (Event, error) { Dispose: disp, ID: id, }, nil + + case ConfessionsChannelLink: + gid, ok := data[ConfessionsLinkEventGuildKey] + if !ok { + return nil, errors.New(BadConfessionsLinkEventError) + } + + cid, ok := data[ConfessionsLinkEventChannelKey] + if !ok { + return nil, errors.New(BadConfessionsLinkEventError) + } + + ti, ok := data[ConfessionsLinkEventCreatedKey] + if !ok { + return nil, errors.New(BadConfessionsLinkEventError) + } + t, err := time.Parse(time.RFC3339, ti) + if err != nil { + return nil, errors.Join(errors.New(BadConfessionsLinkEventError), err) + } + + return ConfessionsChannelLinkEvent{ + GuildID: gid, + ChannelID: cid, + Created: t, + }, nil + default: return nil, errors.New(BadEventTypeError) }