From 00fff032c73de9d932dc259844a7f2b9659c14ba Mon Sep 17 00:00:00 2001 From: Ava Affine Date: Thu, 14 Nov 2024 09:57:06 -0800 Subject: [PATCH 1/5] Add testing CI This commit extends our CI definitions to add a testing phase to the pipeline. The testing phase is intended to be comprised of separate stages per package that has tests included. Currently this consists of the state and config packages. Signed-off-by: Ava Affine --- .gitlab-ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7eecc75..93a2f9b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,6 +2,7 @@ image: golang:latest stages: - build + - test compile: stage: build @@ -11,3 +12,13 @@ compile: paths: - bingobot - start.sh + +tests-state-pkg: + stage: test + script: + - go test ./internal/state + +tests-config-pkg: + stage: test + script: + - go test ./internal/config From 6afd0122c918f8409e8aed0e3b9f3ed8157e35e3 Mon Sep 17 00:00:00 2001 From: Ava Affine Date: Thu, 14 Nov 2024 10:27:45 -0800 Subject: [PATCH 2/5] Fix config unit tests Recent changes to config and logging modules have left the tests needing to initialize both config and logging packages. This commit updates the config module to automatically initialize into at least useful defaults at module load time. This commit also fixes the config unit tests by using the more up to date interface that the package provides. Signed-off-by: Ava Affine --- internal/config/config.go | 12 ++++++++++++ internal/config/config_test.go | 5 +---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 90fc69e..e8f7b5c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,10 +18,22 @@ type AppConfig struct { var config *AppConfig +func init() { + setDefaults() + viper.Unmarshal(&config) +} + func Get() *AppConfig { return config } +func GetDefaultConfig() *AppConfig { + var config *AppConfig + setDefaults() + viper.Unmarshal(&config) + return config +} + func Init() error { setDefaults() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index eb9f022..9fef00f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -25,12 +25,9 @@ log_compression: false func TestDefaultConfigs(t *testing.T) { k := "testdefaultkey" v := "testdefaultval" - viper.SetDefault(k, v) - _, err := Parse() - - if err != nil { + if err := Init(); err != nil { t.Error(err) } From 9668106959a0036db26ce3a9f501c04676c502b9 Mon Sep 17 00:00:00 2001 From: Ava Affine Date: Thu, 14 Nov 2024 10:30:04 -0800 Subject: [PATCH 3/5] Fix state unit tests Logging needs to be initialized in order for the state unit tests to function without issue. See previous commit ("Fix config unit tests") for more information. Signed-off-by: Ava Affine --- internal/state/state_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 3d98b1c..e775e74 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -4,6 +4,8 @@ import ( "fmt" "testing" "time" + + "gitlab.com/whom/bingobot/internal/logging" ) /* WARNING: @@ -12,8 +14,15 @@ import ( */ const TestTok = "TEST_NAME" +var loggingInitialized = false func SetupTest(t *testing.T) { + // have to set up logger + if !loggingInitialized { + logging.Init() + loggingInitialized = true + } + old, _ := time.Parse( time.RFC3339, VeryOldVote, From 9b00241d2bdb40f5e42e12fdaec8c1430a15e3a5 Mon Sep 17 00:00:00 2001 From: Piper Pentagram Date: Wed, 13 Nov 2024 16:32:58 -0800 Subject: [PATCH 4/5] 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 { From fe860726b3dca7210615a8bd1a73cad9aa4668a3 Mon Sep 17 00:00:00 2001 From: Piper Pentagram Date: Thu, 14 Nov 2024 10:58:15 -0800 Subject: [PATCH 5/5] inline _activityTimerExists() into stopActivityTimer() --- internal/discord/activity.go | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/internal/discord/activity.go b/internal/discord/activity.go index a9fab33..afa6c8d 100644 --- a/internal/discord/activity.go +++ b/internal/discord/activity.go @@ -120,20 +120,6 @@ func startActivityTimer(uid string) { 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. @@ -142,7 +128,7 @@ func stopActivityTimer(uid string) { timerMutex.Lock() defer timerMutex.Unlock() - if _activityTimerExists(uid) { + if _, ok := activityTimers[uid]; !ok { activityTimers[uid].Cancel() delete(activityTimers, uid) }