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:
parent
8110037ddf
commit
9b00241d2b
5 changed files with 219 additions and 16 deletions
17
config.yaml
17
config.yaml
|
|
@ -3,4 +3,19 @@ log_dir: "log"
|
||||||
log_max_size_mb: 500
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
165
internal/discord/activity.go
Normal file
165
internal/discord/activity.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue