Merge branch 'ava-confessions' into 'main'

Confessions module

See merge request whom/bingobot!20
This commit is contained in:
piper pentagram 2025-01-26 06:30:14 +00:00
commit 39cd9f6e01
10 changed files with 313 additions and 24 deletions

View file

@ -2,12 +2,13 @@ package activity
import (
"errors"
"fmt"
"sync"
"time"
"gitlab.com/whom/bingobot/internal/config"
"gitlab.com/whom/bingobot/internal/state"
"gitlab.com/whom/bingobot/internal/logging"
"gitlab.com/whom/bingobot/internal/state"
)
/* Activity module
@ -18,7 +19,7 @@ const (
ActivityModuleStartFail = "failed to start activity module"
)
var currentUserActivity map[string][]state.UserActiveEvent
var currentUserActivity = make(map[string][]state.UserActiveEvent)
var userActivityLock sync.RWMutex
func Start() error {
@ -38,6 +39,10 @@ func Start() error {
user := emap[state.UserEventUserKey]
etime := ev.Time()
delta := time.Since(etime).Hours() / float64(24)
logging.Debug(fmt.Sprintf(
"processing UserActive event for %s", user,
))
if delta <= float64(config.Get().UserEventLifespanDays) {
new := []state.UserActiveEvent{ev.(state.UserActiveEvent)}
@ -46,6 +51,7 @@ func Start() error {
if found {
new = append(new, current...)
}
currentUserActivity[user] = new
userActivityLock.Unlock()
} else {
logging.Warn("recieved expired useractive event")

View file

@ -0,0 +1,60 @@
package confession
import (
"errors"
"fmt"
"sync"
"github.com/bwmarrin/discordgo"
"gitlab.com/whom/bingobot/internal/logging"
"gitlab.com/whom/bingobot/internal/state"
)
/* Activity module
* This module posts anonymous confessions according to a linked channel map
*/
const (
ActivityModuleStartFail = "failed to start activity module"
)
var (
// guild ID to channel ID
linkLock sync.RWMutex
confessionChannelLinks = make(map[string]state.ConfessionsChannelLinkEvent)
)
func Start() error {
ch, err := state.ConfessionsChannelLink.Subscribe()
if err != nil {
return errors.Join(
errors.New(ActivityModuleStartFail),
err,
)
}
// process incoming events loop
go func() {
for {
ev := <- ch
logging.Debug("recieved new confessional channel link")
e := ev.(state.ConfessionsChannelLinkEvent)
linkLock.Lock()
confessionChannelLinks[e.GuildID] = e
linkLock.Unlock()
}
}()
return nil
}
func MakeConfession(s *discordgo.Session, guildID string, content string) {
linkLock.RLock()
link, ok := confessionChannelLinks[guildID]
linkLock.RUnlock()
if !ok {
logging.Error(fmt.Sprintf("Failed to send confession in guild %s: no link exists in map", guildID))
return
}
s.ChannelMessageSend(link.ChannelID, content)
}

View file

@ -42,7 +42,7 @@ func NewActivityTimer(uid string) *UserActivityTimer {
Start() initializes the timer and calls run()
*/
func (t *UserActivityTimer) Start(ctx context.Context) {
logging.Info("starting voiceActivityTimer", "uid", t.UID)
logging.Debug("starting voiceActivityTimer", "uid", t.UID)
t.sleepDuration = time.Millisecond * time.Duration(config.Get().VoiceActivityTimerSleepIntervalMillis)
activityTimerDuration := time.Second * time.Duration(config.Get().VoiceActivityThresholdSeconds)
@ -89,7 +89,7 @@ func (t *UserActivityTimer) run() {
}
// the timer's context has been cancelled or deadline expired.
logging.Info("voiceActivityTimer stopping", "uid", t.UID, "reason", context.Cause(t.ctx))
logging.Debug("voiceActivityTimer stopping", "uid", t.UID, "reason", context.Cause(t.ctx))
if context.Cause(t.ctx) == ErrTimerExpired {
/*
@ -128,7 +128,7 @@ func stopActivityTimer(uid string) {
timerMutex.Lock()
defer timerMutex.Unlock()
if _, ok := activityTimers[uid]; !ok {
if _, ok := activityTimers[uid]; ok {
activityTimers[uid].Cancel()
delete(activityTimers, uid)
}
@ -147,5 +147,5 @@ func emitUserActiveEvent(uid string) {
return
}
logging.Info("published UserActiveEvent", "uid", uid)
logging.Debug("published UserActiveEvent", "uid", uid)
}

View file

@ -0,0 +1,125 @@
package discord
import (
"fmt"
"time"
"github.com/bwmarrin/discordgo"
"gitlab.com/whom/bingobot/internal/confession"
"gitlab.com/whom/bingobot/internal/logging"
"gitlab.com/whom/bingobot/internal/state"
)
var (
// map of guildID to registeredCommands
registeredCommands = make(map[string][]*discordgo.ApplicationCommand)
// all commands
commandList = []*discordgo.ApplicationCommand{
// TODO: Limit usage somehow?
// maybe delete this and use the vote module instead
&discordgo.ApplicationCommand{
Name: "confessional",
Description: "mark a channel as a designated confessional for a guild",
},
&discordgo.ApplicationCommand{
Name: "confess",
Description: "anonymously post a confession in configured channel",
Options: []*discordgo.ApplicationCommandOption{
&discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionString,
Name: "confession",
Description: "A confession to be posted anonymously",
Required: true,
},
},
},
}
commandHandlers = map[string]func(
s *discordgo.Session,
i *discordgo.InteractionCreate,
) {
"confessional": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
if err := state.PublishEvent(state.ConfessionsChannelLinkEvent{
GuildID: i.GuildID,
ChannelID: i.ChannelID,
Created: time.Now(),
}); err != nil {
logging.Error(fmt.Sprintf(
"failed to publish confession channel link: %s",
err.Error(),
))
} else {
logging.Info("published confession channel link")
}
},
// handle a confession
"confess": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
for _, v := range i.ApplicationCommandData().Options {
if v.Name == "confession" {
confession.MakeConfession(s, i.GuildID, v.StringValue())
}
}
},
}
)
func handleCommand(s *discordgo.Session, e *discordgo.InteractionCreate) {
name := e.ApplicationCommandData().Name
// TODO: audit log
if h, ok := commandHandlers[name]; ok {
h(s, e)
} else {
logging.Debug("no handler for command: %s", name)
}
s.InteractionRespond(e.Interaction, &discordgo.InteractionResponse{
// TODO: Some silent ACK
})
}
func registerCommands(s *discordgo.Session) {
for _, guild := range s.State.Guilds {
cmds, err := s.ApplicationCommandBulkOverwrite(
s.State.Application.ID,
guild.ID,
commandList,
)
if err != nil {
logging.Error(fmt.Sprintf(
"Failed to register commands for guild %s: %s",
guild.ID, err.Error(),
))
} else {
logging.Info(fmt.Sprintf("Registered commands for guild %s", guild.ID))
registeredCommands[guild.ID] = cmds
}
}
}
func deregisterCommands(s *discordgo.Session) {
for guild, commands := range registeredCommands {
for _, cmd := range commands {
if err := s.ApplicationCommandDelete(
s.State.Application.ID,
guild,
cmd.ID,
); err != nil {
logging.Error(fmt.Sprintf(
"Failed to delete %s command (id: %s) from guild %s",
cmd.Name, cmd.ID, guild,
))
} else {
logging.Info(fmt.Sprintf(
"Deregistered command %s (id: %s) from guild %s",
cmd.Name, cmd.ID, guild,
))
}
}
}
}

View file

@ -36,10 +36,13 @@ func Connect(token string) error {
return fmt.Errorf("failed to open discord session: %s", err)
}
registerCommands(session.s)
return nil
}
func Close() {
deregisterCommands(session.s)
err := session.s.Close()
if err != nil {

View file

@ -9,6 +9,7 @@ func addHandlers() {
session.s.AddHandler(handleConnect)
session.s.AddHandler(handleDisconnect)
session.s.AddHandler(handleVoiceStateUpdate)
session.s.AddHandler(handleCommand) // handles InteractionCreate
}
func handleConnect(s *discordgo.Session, e *discordgo.Connect) {

View file

@ -226,3 +226,47 @@ func (te TestEvent) Validate() error {
func (te TestEvent) Disposable() bool {
return te.Dispose
}
const (
ConfessionsLinkEventGuildKey = "guild_id"
ConfessionsLinkEventChannelKey = "channel_id"
ConfessionsLinkEventCreatedKey = "created"
ConfessionsLinkEventObsoleteKey = "obsolete"
BadConfessionsLinkEventError = "link event doesnt have required fields"
)
type ConfessionsChannelLinkEvent struct {
GuildID string
ChannelID string
Created time.Time
}
func (e ConfessionsChannelLinkEvent) Type() EventType {
return ConfessionsChannelLink
}
func (e ConfessionsChannelLinkEvent) Time() time.Time {
return e.Time()
}
func (e ConfessionsChannelLinkEvent) Data() map[string]string {
return map[string]string{
ConfessionsLinkEventGuildKey: e.GuildID,
ConfessionsLinkEventChannelKey: e.ChannelID,
ConfessionsLinkEventCreatedKey: e.Created.Format(time.RFC3339),
}
}
func (e ConfessionsChannelLinkEvent) Validate() error {
if len(e.ChannelID) <= 1 || len(e.GuildID) <= 1 {
return errors.New(BadConfessionsLinkEventError)
}
return nil
}
func (e ConfessionsChannelLinkEvent) Disposable() bool {
// TODO
return false
}

View file

@ -159,6 +159,7 @@ func Start() error {
// return no error. we are handling SIGINT
func Teardown() {
logging.Warn("Tearing down state engine")
// riskily ignore all locking....
// we are handling a termination signal after all
i, e := eventStream.Close()
@ -167,6 +168,7 @@ func Teardown() {
logging.Warn("will attempt to truncate event store anyways")
}
logging.Warn("teardown: events flushed")
e = os.Truncate(eventStorageFileName, i)
if e != nil {
logging.Error("FAILED TO TRUNCATE EVENT STORE!!")
@ -183,6 +185,7 @@ const (
Restoration
UserActive
Test
ConfessionsChannelLink
// ...
// leave this last
@ -202,6 +205,8 @@ func EventTypeFromString(doc string) EventType {
return UserActive
case "Test":
return Test
case "ConfessionsChannelLink":
return ConfessionsChannelLink
default:
// error case
return NumEventTypes
@ -215,6 +220,7 @@ func (et EventType) String() string {
"Restoration",
"UserActive",
"Test",
"ConfessionsChannelLink",
}
if et < 0 || et >= NumEventTypes {
@ -266,24 +272,28 @@ func (et EventType) MakeEvent(data map[string]string) (Event, error) {
switch et {
case Vote:
return VoteEvent(data), nil
case Challenge:
e, err := MakeUserEvent(data)
if err != nil {
return nil, errors.Join(errors.New(BadChallengeEvent), err)
}
return ChallengeEvent{*e}, nil
case Restoration:
e, err := MakeUserEvent(data)
if err != nil {
return nil, errors.Join(errors.New(BadRestorationEvent), err)
}
return RestorationEvent{*e}, nil
case UserActive:
e, err := MakeUserEvent(data)
if err != nil {
return nil, errors.Join(errors.New(BadUserActiveEvent), err)
}
return UserActiveEvent{*e}, nil
case Test:
disp := false
if v, ok := data[TestEventDisposeKey]; ok && v == "t" {
@ -300,6 +310,33 @@ func (et EventType) MakeEvent(data map[string]string) (Event, error) {
Dispose: disp,
ID: id,
}, nil
case ConfessionsChannelLink:
gid, ok := data[ConfessionsLinkEventGuildKey]
if !ok {
return nil, errors.New(BadConfessionsLinkEventError)
}
cid, ok := data[ConfessionsLinkEventChannelKey]
if !ok {
return nil, errors.New(BadConfessionsLinkEventError)
}
ti, ok := data[ConfessionsLinkEventCreatedKey]
if !ok {
return nil, errors.New(BadConfessionsLinkEventError)
}
t, err := time.Parse(time.RFC3339, ti)
if err != nil {
return nil, errors.Join(errors.New(BadConfessionsLinkEventError), err)
}
return ConfessionsChannelLinkEvent{
GuildID: gid,
ChannelID: cid,
Created: t,
}, nil
default:
return nil, errors.New(BadEventTypeError)
}

View file

@ -14,6 +14,7 @@ func Start() error {
http.HandleFunc("/", HandleHttpRequest)
go logging.Error(http.ListenAndServe(frag, nil).Error())
logging.Debug("Web handlers started")
return nil
}

48
main.go
View file

@ -3,8 +3,11 @@ package main
import (
"flag"
"log"
"os"
"os/signal"
"gitlab.com/whom/bingobot/internal/activity"
"gitlab.com/whom/bingobot/internal/confession"
"gitlab.com/whom/bingobot/internal/config"
"gitlab.com/whom/bingobot/internal/discord"
"gitlab.com/whom/bingobot/internal/logging"
@ -28,6 +31,7 @@ func main() {
logging.Init()
flag.Parse()
logging.Info("startup: initializing state engine")
if err := state.Init(
config.Get().InMemoryEventCacheSize,
config.Get().PersistentCacheStore,
@ -35,35 +39,43 @@ func main() {
log.Fatalf("couldn't initialize state engine: %s", err.Error())
}
logging.Info("startup: starting activity module")
if err := activity.Start(); err != nil {
// TODO: handle gracefully and continue?
log.Fatalf("failed to start activity module: %s", err.Error())
}
if err := web.Start(); err != nil {
log.Fatalf("failed to start local web server: %s", err.Error())
logging.Info("startup: starting confession module")
if err := confession.Start(); err != nil {
log.Fatalf("failed to start confession module: %s", err.Error())
}
logging.Info("startup: starting web module")
go func() {
if err := web.Start(); err != nil {
log.Fatalf("failed to start local web server: %s", err.Error())
}
}()
// start this LAST
logging.Info("startup: starting state engine")
if err := state.Start(); err != nil {
log.Fatalf("failed to start state machine: %s", err.Error())
}
defer state.Teardown()
if err := startBot(); err != nil {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
go func(){
for _ = range c {
state.Teardown()
discord.Close()
os.Exit(1)
}
}()
logging.Info("startup: connecting to discord")
if err := discord.Connect(*token); err != nil {
log.Fatal(err)
}
}
func startBot() error {
err := discord.Connect(*token)
if err != nil {
return err
}
logging.Info("shutting down gracefully", "type", "shutdown")
discord.Close()
return nil
for {}
}