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..afa6c8d --- /dev/null +++ b/internal/discord/activity.go @@ -0,0 +1,151 @@ +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()) +} + +/* +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 _, ok := activityTimers[uid]; !ok { + 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 {