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.
This commit is contained in:
Piper Pentagram 2024-11-13 16:32:58 -08:00
parent 8110037ddf
commit 9b00241d2b
5 changed files with 219 additions and 16 deletions

View file

@ -4,3 +4,18 @@ log_max_size_mb: 500
log_max_backups: 3 log_max_backups: 3
log_max_age_days: 365 log_max_age_days: 365
log_compression: false 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

View file

@ -14,6 +14,25 @@ type AppConfig struct {
LogMaxAgeDays int `yaml:"log_max_age_days"` LogMaxAgeDays int `yaml:"log_max_age_days"`
LogCompression bool `yaml:"log_compression"` LogCompression bool `yaml:"log_compression"`
LogAddSource bool `yaml:"log_add_source"` 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 var config *AppConfig
@ -66,4 +85,6 @@ func setDefaults() {
viper.SetDefault("LogMaxAgeDays", 365) viper.SetDefault("LogMaxAgeDays", 365)
viper.SetDefault("LogCompression", false) viper.SetDefault("LogCompression", false)
viper.SetDefault("LogAddSource", true) viper.SetDefault("LogAddSource", true)
viper.SetDefault("VoiceActivityThresholdSeconds", 600)
viper.SetDefault("VoiceActivityTimerSleepIntervalMillis", 1000)
} }

View file

@ -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)
}

View file

@ -8,6 +8,7 @@ import (
func addHandlers() { func addHandlers() {
session.s.AddHandler(handleConnect) session.s.AddHandler(handleConnect)
session.s.AddHandler(handleDisconnect) session.s.AddHandler(handleDisconnect)
session.s.AddHandler(handleVoiceStateUpdate)
} }
func handleConnect(s *discordgo.Session, e *discordgo.Connect) { func handleConnect(s *discordgo.Session, e *discordgo.Connect) {
@ -19,3 +20,17 @@ func handleDisconnect(s *discordgo.Session, e *discordgo.Disconnect) {
session.connected = false session.connected = false
logging.Info("discord session disconnected") 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)
}

View file

@ -13,7 +13,6 @@ import (
"sync" "sync"
"time" "time"
"gitlab.com/whom/bingobot/internal/discord"
"gitlab.com/whom/bingobot/internal/logging" "gitlab.com/whom/bingobot/internal/logging"
) )
@ -359,20 +358,8 @@ func (ue UserEvent) Data() map[string]string {
} }
func (ue UserEvent) Validate() error { func (ue UserEvent) Validate() error {
if discord.Connected() { // empty for now, we may do some validation later.
_, err := discord.User(ue.uid) return nil
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
}
} }
type ChallengeEvent struct { type ChallengeEvent struct {