From 9b00241d2bdb40f5e42e12fdaec8c1430a15e3a5 Mon Sep 17 00:00:00 2001 From: Piper Pentagram Date: Wed, 13 Nov 2024 16:32:58 -0800 Subject: [PATCH] Implement UserActive events This change introduces the UserActiveTimer, which tracks voice activity and emits UserActive events. UserActiveTimer is basically a fancy wrapper around a context with a deadline and cancelFunc. When a user joins a voice channel, a UserActiveTimer is started. If the user stays in the voice channel for an amount of time defined in the configs, the timer context's deadline trips and a UserActive event is emitted. A new timer is then started. If instead the user leaves the voice channel, the timer's context is cancelled. This change introduces two config values to manage this process: VoiceActivityThresholdSeconds defines the length of time a user is required to stay in vc before a UserActive event is generated. VoiceActivityTimerSleepInterval defines how long the timer sleeps at any one time. After this interval, it wakes up to check if its context has been cancelled. This change also changes the state package's UserEvent validation to remove an import loop. We will now assume that the discord package has already validated any UIDs it passes along to the state package. --- config.yaml | 17 +++- internal/config/config.go | 21 +++++ internal/discord/activity.go | 165 +++++++++++++++++++++++++++++++++++ internal/discord/handlers.go | 15 ++++ internal/state/state.go | 17 +--- 5 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 internal/discord/activity.go diff --git a/config.yaml b/config.yaml index fe28839..9fb6fd4 100644 --- a/config.yaml +++ b/config.yaml @@ -3,4 +3,19 @@ log_dir: "log" log_max_size_mb: 500 log_max_backups: 3 log_max_age_days: 365 -log_compression: false \ No newline at end of file +log_compression: false + +# how long (in seconds) a user needs to be in vc +# in order to generate a UserActive event +voice_activity_threshold_seconds: 600 + +# how long (in milliseconds) a voice activity timer sleeps at a time between +# context cancellation checks. + +# a higher value means the function sleeps longer which could be +# useful for some reason in the future + +# a higher value also means that the timer could take longer to cancel. + +# current recommended value is 1000ms. +voice_activity_timer_sleep_interval_millis: 1000 diff --git a/internal/config/config.go b/internal/config/config.go index e8f7b5c..00e1983 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,25 @@ type AppConfig struct { LogMaxAgeDays int `yaml:"log_max_age_days"` LogCompression bool `yaml:"log_compression"` LogAddSource bool `yaml:"log_add_source"` + + /* + how long (in seconds) a user needs to be in vc in order to generate a + UserActive event + */ + VoiceActivityThresholdSeconds int `yaml:"voice_activity_threshold_seconds"` + + /* + how long (in milliseconds) a voice activity timer sleeps at a time between + context cancellation checks. + + a higher value means the function sleeps longer which could be + useful for some reason in the future + + a higher value also means that the timer could take longer to cancel. + + current recommended value is 1000ms. + */ + VoiceActivityTimerSleepIntervalMillis int `yaml:"voice_activity_timer_sleep_interval_millis"` } var config *AppConfig @@ -66,4 +85,6 @@ func setDefaults() { viper.SetDefault("LogMaxAgeDays", 365) viper.SetDefault("LogCompression", false) viper.SetDefault("LogAddSource", true) + viper.SetDefault("VoiceActivityThresholdSeconds", 600) + viper.SetDefault("VoiceActivityTimerSleepIntervalMillis", 1000) } diff --git a/internal/discord/activity.go b/internal/discord/activity.go new file mode 100644 index 0000000..a9fab33 --- /dev/null +++ b/internal/discord/activity.go @@ -0,0 +1,165 @@ +package discord + +import ( + "context" + "errors" + "sync" + "time" + + "gitlab.com/whom/bingobot/internal/config" + "gitlab.com/whom/bingobot/internal/logging" + "gitlab.com/whom/bingobot/internal/state" +) + +/* + Stuff having to do with tracking and responding to user activity will be kept here. +*/ + +var activityTimers map[string]*UserActivityTimer +var timerMutex sync.RWMutex + +var ErrTimerExpired = errors.New("timer expired") + +func init() { + activityTimers = map[string]*UserActivityTimer{} +} + +type UserActivityTimer struct { + UID string + Cancel context.CancelFunc + ctx context.Context + startTime time.Time + sleepDuration time.Duration +} + +func NewActivityTimer(uid string) *UserActivityTimer { + return &UserActivityTimer{ + UID: uid, + } +} + +/* +Start() initializes the timer and calls run() +*/ +func (t *UserActivityTimer) Start(ctx context.Context) { + logging.Info("starting voiceActivityTimer", "uid", t.UID) + + t.sleepDuration = time.Millisecond * time.Duration(config.Get().VoiceActivityTimerSleepIntervalMillis) + activityTimerDuration := time.Second * time.Duration(config.Get().VoiceActivityThresholdSeconds) + + t.startTime = time.Now() + + t.ctx, t.Cancel = context.WithDeadlineCause( + ctx, + t.startTime.Add(activityTimerDuration), + ErrTimerExpired, + ) + + t.run() +} + +/* +shouldStop() returns true for one of two reasons: + + 1. the context is manually cancelled. this happens + when the bot is shutting down and we must clean up. + + 2. the context's deadline expires naturally when it has + reached its end time. + + both cases manifest as the ctx.Done() channel + being non-empty. +*/ +func (t *UserActivityTimer) shouldStop() bool { + select { + case <-t.ctx.Done(): + return true + default: + return false + } +} + +/* +run() performs the tick loop that provides the timer functionality. +it is called from Start(). +*/ +func (t *UserActivityTimer) run() { + for !t.shouldStop() { + <-time.Tick(t.sleepDuration) + } + + // the timer's context has been cancelled or deadline expired. + logging.Info("voiceActivityTimer stopping", "uid", t.UID, "reason", context.Cause(t.ctx)) + + if context.Cause(t.ctx) == ErrTimerExpired { + /* + we should start a new timer to replace this one + */ + emitUserActiveEvent(t.UID) + defer startActivityTimer(t.UID) + + return + } + + stopActivityTimer(t.UID) +} + +/* +startActivityTimer() creates and starts a UserActivityTimer for +the specified uid. + +if one already exists, it will be cancelled and recreated. +*/ +func startActivityTimer(uid string) { + stopActivityTimer(uid) // if a timer already exists, stop it + + timerMutex.Lock() + defer timerMutex.Unlock() + activityTimers[uid] = NewActivityTimer(uid) + + go activityTimers[uid].Start(context.Background()) +} + +/* +_activityTimerExists() returns true if there is already an activity timer for +the specified uid + +WARNING: does not lock. do not call without locking. +*/ +func _activityTimerExists(uid string) bool { + if _, ok := activityTimers[uid]; !ok { + return false + } + + return true +} + +/* +stopActivityTimer() cancels any running timer for the given uid +and removes it from the activityTimers map. +*/ +func stopActivityTimer(uid string) { + timerMutex.Lock() + defer timerMutex.Unlock() + + if _activityTimerExists(uid) { + activityTimers[uid].Cancel() + delete(activityTimers, uid) + } +} + +/* +emitUserActiveEvent() is called when a UserActivityTimer reaches its deadline without being cancelled. +this represents a user having reached the defined VoiceActivityThresholdSeconds +in the config. +*/ +func emitUserActiveEvent(uid string) { + err := state.PublishEvent(state.NewUserActiveEvent(uid)) + + if err != nil { + logging.Error("failed to publish UserActiveEvent", "uid", uid, "err", err) + return + } + + logging.Info("published UserActiveEvent", "uid", uid) +} diff --git a/internal/discord/handlers.go b/internal/discord/handlers.go index c2d2c25..c01d441 100644 --- a/internal/discord/handlers.go +++ b/internal/discord/handlers.go @@ -8,6 +8,7 @@ import ( func addHandlers() { session.s.AddHandler(handleConnect) session.s.AddHandler(handleDisconnect) + session.s.AddHandler(handleVoiceStateUpdate) } func handleConnect(s *discordgo.Session, e *discordgo.Connect) { @@ -19,3 +20,17 @@ func handleDisconnect(s *discordgo.Session, e *discordgo.Disconnect) { session.connected = false logging.Info("discord session disconnected") } + +func handleVoiceStateUpdate(_ *discordgo.Session, e *discordgo.VoiceStateUpdate) { + if e.ChannelID == "" { + // user disconnected + logging.Info("user left channel", "uid", e.UserID) + stopActivityTimer(e.UserID) + + return + } + + // user connected + logging.Info("user joined channel", "uid", e.UserID, "channel", e.ChannelID) + startActivityTimer(e.UserID) +} diff --git a/internal/state/state.go b/internal/state/state.go index 8a0ea7c..caba329 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -13,7 +13,6 @@ import ( "sync" "time" - "gitlab.com/whom/bingobot/internal/discord" "gitlab.com/whom/bingobot/internal/logging" ) @@ -359,20 +358,8 @@ func (ue UserEvent) Data() map[string]string { } func (ue UserEvent) Validate() error { - if discord.Connected() { - _, err := discord.User(ue.uid) - return err - } else { - // I would love to know how to actually fail here - // and still have unit testable code. - - logging.Error( - "can't validate UserEvent: nil discord session", - "event", - fmt.Sprintf("%+v", ue), - ) - return nil - } + // empty for now, we may do some validation later. + return nil } type ChallengeEvent struct {