diff --git a/internal/activity/activity.go b/internal/activity/activity.go index 9430085..488de9d 100644 --- a/internal/activity/activity.go +++ b/internal/activity/activity.go @@ -2,12 +2,13 @@ package activity import ( "errors" + "fmt" "sync" "time" "gitlab.com/whom/bingobot/internal/config" - "gitlab.com/whom/bingobot/internal/state" "gitlab.com/whom/bingobot/internal/logging" + "gitlab.com/whom/bingobot/internal/state" ) /* Activity module @@ -18,7 +19,7 @@ const ( ActivityModuleStartFail = "failed to start activity module" ) -var currentUserActivity map[string][]state.UserActiveEvent +var currentUserActivity = make(map[string][]state.UserActiveEvent) var userActivityLock sync.RWMutex func Start() error { @@ -38,6 +39,10 @@ func Start() error { user := emap[state.UserEventUserKey] etime := ev.Time() delta := time.Since(etime).Hours() / float64(24) + logging.Debug(fmt.Sprintf( + "processing UserActive event for %s", user, + )) + if delta <= float64(config.Get().UserEventLifespanDays) { new := []state.UserActiveEvent{ev.(state.UserActiveEvent)} @@ -46,6 +51,7 @@ func Start() error { if found { new = append(new, current...) } + currentUserActivity[user] = new userActivityLock.Unlock() } else { logging.Warn("recieved expired useractive event") diff --git a/internal/confession/confession.go b/internal/confession/confession.go new file mode 100644 index 0000000..d76aa38 --- /dev/null +++ b/internal/confession/confession.go @@ -0,0 +1,60 @@ +package confession + +import ( + "errors" + "fmt" + "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 = make(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 + logging.Debug("recieved new confessional channel link") + 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(fmt.Sprintf("Failed to send confession in guild %s: no link exists in map", guildID)) + return + } + s.ChannelMessageSend(link.ChannelID, content) +} diff --git a/internal/discord/activity.go b/internal/discord/activity.go index afa6c8d..002f3b4 100644 --- a/internal/discord/activity.go +++ b/internal/discord/activity.go @@ -42,7 +42,7 @@ func NewActivityTimer(uid string) *UserActivityTimer { Start() initializes the timer and calls run() */ func (t *UserActivityTimer) Start(ctx context.Context) { - logging.Info("starting voiceActivityTimer", "uid", t.UID) + logging.Debug("starting voiceActivityTimer", "uid", t.UID) t.sleepDuration = time.Millisecond * time.Duration(config.Get().VoiceActivityTimerSleepIntervalMillis) activityTimerDuration := time.Second * time.Duration(config.Get().VoiceActivityThresholdSeconds) @@ -89,7 +89,7 @@ func (t *UserActivityTimer) run() { } // the timer's context has been cancelled or deadline expired. - logging.Info("voiceActivityTimer stopping", "uid", t.UID, "reason", context.Cause(t.ctx)) + logging.Debug("voiceActivityTimer stopping", "uid", t.UID, "reason", context.Cause(t.ctx)) if context.Cause(t.ctx) == ErrTimerExpired { /* @@ -128,7 +128,7 @@ func stopActivityTimer(uid string) { timerMutex.Lock() defer timerMutex.Unlock() - if _, ok := activityTimers[uid]; !ok { + if _, ok := activityTimers[uid]; ok { activityTimers[uid].Cancel() delete(activityTimers, uid) } @@ -147,5 +147,5 @@ func emitUserActiveEvent(uid string) { return } - logging.Info("published UserActiveEvent", "uid", uid) + logging.Debug("published UserActiveEvent", "uid", uid) } diff --git a/internal/discord/commands.go b/internal/discord/commands.go new file mode 100644 index 0000000..c774dc3 --- /dev/null +++ b/internal/discord/commands.go @@ -0,0 +1,129 @@ +package discord + +import ( + "fmt" + "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 = make(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{ + Type: discordgo.ApplicationCommandOptionString, + 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) { + if err := state.PublishEvent(state.ConfessionsChannelLinkEvent{ + GuildID: i.GuildID, + ChannelID: i.ChannelID, + Created: time.Now(), + }); err != nil { + logging.Error(fmt.Sprintf( + "failed to publish confession channel link: %s", + err.Error(), + )) + } else { + logging.Info("published confession channel link") + } + }, + + // 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) + } + + s.InteractionRespond(e.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Flags: discordgo.MessageFlagsEphemeral, + Content: "Your cauldron bubbles...", + }, + }) +} + +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(fmt.Sprintf( + "Failed to register commands for guild %s: %s", + guild.ID, err.Error(), + )) + } else { + logging.Info(fmt.Sprintf("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(fmt.Sprintf( + "Failed to delete %s command (id: %s) from guild %s", + cmd.Name, cmd.ID, guild, + )) + } else { + logging.Info(fmt.Sprintf( + "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..44ad0f4 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..cc439f5 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -159,6 +159,7 @@ func Start() error { // return no error. we are handling SIGINT func Teardown() { + logging.Warn("Tearing down state engine") // riskily ignore all locking.... // we are handling a termination signal after all i, e := eventStream.Close() @@ -167,6 +168,7 @@ func Teardown() { logging.Warn("will attempt to truncate event store anyways") } + logging.Warn("teardown: events flushed") e = os.Truncate(eventStorageFileName, i) if e != nil { logging.Error("FAILED TO TRUNCATE EVENT STORE!!") @@ -183,6 +185,7 @@ const ( Restoration UserActive Test + ConfessionsChannelLink // ... // leave this last @@ -202,6 +205,8 @@ func EventTypeFromString(doc string) EventType { return UserActive case "Test": return Test + case "ConfessionsChannelLink": + return ConfessionsChannelLink default: // error case return NumEventTypes @@ -215,6 +220,7 @@ func (et EventType) String() string { "Restoration", "UserActive", "Test", + "ConfessionsChannelLink", } if et < 0 || et >= NumEventTypes { @@ -266,24 +272,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 +310,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) } diff --git a/internal/web/web.go b/internal/web/web.go index 38051e6..fbe65eb 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -14,6 +14,7 @@ func Start() error { http.HandleFunc("/", HandleHttpRequest) go logging.Error(http.ListenAndServe(frag, nil).Error()) + logging.Debug("Web handlers started") return nil } diff --git a/main.go b/main.go index 0528e1c..96e2e36 100644 --- a/main.go +++ b/main.go @@ -3,8 +3,11 @@ package main import ( "flag" "log" + "os" + "os/signal" "gitlab.com/whom/bingobot/internal/activity" + "gitlab.com/whom/bingobot/internal/confession" "gitlab.com/whom/bingobot/internal/config" "gitlab.com/whom/bingobot/internal/discord" "gitlab.com/whom/bingobot/internal/logging" @@ -28,6 +31,7 @@ func main() { logging.Init() flag.Parse() + logging.Info("startup: initializing state engine") if err := state.Init( config.Get().InMemoryEventCacheSize, config.Get().PersistentCacheStore, @@ -35,35 +39,43 @@ func main() { log.Fatalf("couldn't initialize state engine: %s", err.Error()) } + logging.Info("startup: starting activity module") if err := activity.Start(); err != nil { - // TODO: handle gracefully and continue? log.Fatalf("failed to start activity module: %s", err.Error()) } - if err := web.Start(); err != nil { - log.Fatalf("failed to start local web server: %s", err.Error()) + logging.Info("startup: starting confession module") + if err := confession.Start(); err != nil { + log.Fatalf("failed to start confession module: %s", err.Error()) } + logging.Info("startup: starting web module") + go func() { + if err := web.Start(); err != nil { + log.Fatalf("failed to start local web server: %s", err.Error()) + } + }() + // start this LAST + logging.Info("startup: starting state engine") if err := state.Start(); err != nil { log.Fatalf("failed to start state machine: %s", err.Error()) } - defer state.Teardown() - if err := startBot(); err != nil { + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + go func(){ + for _ = range c { + state.Teardown() + discord.Close() + os.Exit(1) + } + }() + + logging.Info("startup: connecting to discord") + if err := discord.Connect(*token); err != nil { log.Fatal(err) } -} - -func startBot() error { - err := discord.Connect(*token) - - if err != nil { - return err - } - - logging.Info("shutting down gracefully", "type", "shutdown") - discord.Close() - - return nil + + for {} }