diff --git a/database/database.go b/database/database.go index 0b89520..8ddc8dd 100644 --- a/database/database.go +++ b/database/database.go @@ -7,10 +7,12 @@ import ( var ( firedBucket = []byte("fired") unbanBucket = []byte("unban") + trackBucket = []byte("track") buckets = [][]byte{ firedBucket, unbanBucket, + trackBucket, } ) diff --git a/database/track.go b/database/track.go new file mode 100644 index 0000000..2f84dee --- /dev/null +++ b/database/track.go @@ -0,0 +1,33 @@ +package database + +import ( + "encoding/json" + "git.jojodev.com/Minecraft/canopeas/discord/track" + "go.etcd.io/bbolt" +) + +// LoadTracker loads the tracks from the database +func (db *Database) LoadTracker() (track.Tracker, error) { + t := track.New() + return t, db.db.View(func(tx *bbolt.Tx) error { + return tx.Bucket(trackBucket).ForEach(func(k, v []byte) error { + var users map[string]struct{} + if err := json.Unmarshal(v, &users); err != nil { + return err + } + t[string(k)] = users + return nil + }) + }) +} + +// SetTrack sets a list of trackers for a word +func (db *Database) SetTrack(word string, trackers map[string]struct{}) error { + return db.db.Update(func(tx *bbolt.Tx) error { + v, err := json.Marshal(trackers) + if err != nil { + return err + } + return tx.Bucket(trackBucket).Put([]byte(word), v) + }) +} diff --git a/discord/discord.go b/discord/discord.go index d708858..0e661e9 100644 --- a/discord/discord.go +++ b/discord/discord.go @@ -2,6 +2,7 @@ package discord import ( "fmt" + "git.jojodev.com/Minecraft/canopeas/discord/track" "os" "strings" "time" @@ -67,6 +68,12 @@ func Bot(cfg *config.Config, db *database.Database) (*discordgo.Session, error) twitterHttpClient := twitterConfig.Client(oauth1.NoContext, twitterToken) twitterClient := twitter.NewClient(twitterHttpClient) + // Init tracker + t, err := db.LoadTracker() + if err != nil { + return nil, err + } + // Init Unban Schedule sched := &unbanSchedule{ db: db, @@ -104,7 +111,9 @@ func Bot(cfg *config.Config, db *database.Database) (*discordgo.Session, error) bot.AddHandler(joinHandler(cfg)) bot.AddHandler(leaveHandler(cfg)) bot.AddHandler(commandHandler(cfg, db, sapi, twitterClient)) - bot.AddHandler(messageHandler(cfg, db)) + bot.AddHandler(pingHandler(cfg, db)) + bot.AddHandler(trackHandler(t)) + bot.AddHandler(trackCommandHandler(t, db)) bot.AddHandler(reactionAddHandler()) bot.AddHandler(reactionRemoveHandler()) @@ -118,6 +127,13 @@ func Bot(cfg *config.Config, db *database.Database) (*discordgo.Session, error) // Intents bot.Identify.Intents = discordgo.MakeIntent(discordgo.IntentsAll) + // Create application commands + if _, err := bot.ApplicationCommandBulkOverwrite(bot.State.User.ID, "", []*discordgo.ApplicationCommand{ + trackCommand, + }); err != nil { + log.Err(err).Msg("could not create application commands") + } + return bot, nil } @@ -232,7 +248,7 @@ func commandHandler(cfg *config.Config, db *database.Database, sapi *serverapi.C } } -func messageHandler(cfg *config.Config, db *database.Database) func(s *discordgo.Session, m *discordgo.MessageCreate) { +func pingHandler(cfg *config.Config, db *database.Database) func(s *discordgo.Session, m *discordgo.MessageCreate) { return func(s *discordgo.Session, m *discordgo.MessageCreate) { // Ignore bots if m.Author.Bot { @@ -250,6 +266,25 @@ func messageHandler(cfg *config.Config, db *database.Database) func(s *discordgo } } +func trackHandler(t track.Tracker) func(s *discordgo.Session, m *discordgo.MessageCreate) { + return func(s *discordgo.Session, m *discordgo.MessageCreate) { + // Ignore bots + if m.Author.Bot { + return + } + + trackers := t.Search(strings.ToLower(m.Content)) + for _, tracker := range trackers { + // Ignore self-trackers + if tracker == m.Author.ID { + continue + } + sendMessage(s, tracker, fmt.Sprintf("%s\n>>>%s", m.Author.Mention(), m.Content), true) + sendMessage(s, tracker, fmt.Sprintf("https://discord.com/channels/%s/%s/%s", m.GuildID, m.ChannelID, m.ID), true) + } + } +} + func reactionAddHandler() func(s *discordgo.Session, m *discordgo.MessageReactionAdd) { return func(s *discordgo.Session, m *discordgo.MessageReactionAdd) { reactionHandler(true, s, m.MessageReaction) @@ -290,7 +325,7 @@ func updateStatus(s *discordgo.Session) { dj, err := newDadJoke() if err != nil { log.Warn().Msgf("could not get new dad joke: %v", err) - } else if err := s.UpdateStatus(1, dj.Joke); err != nil { + } else if err := s.UpdateGameStatus(1, dj.Joke); err != nil { log.Warn().Msgf("could not update status: %v", err) } <-ticker.C diff --git a/discord/track.go b/discord/track.go new file mode 100644 index 0000000..a2555f2 --- /dev/null +++ b/discord/track.go @@ -0,0 +1,74 @@ +package discord + +import ( + "fmt" + "git.jojodev.com/Minecraft/canopeas/database" + "git.jojodev.com/Minecraft/canopeas/discord/track" + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog/log" + "strings" +) + +var trackCommand = &discordgo.ApplicationCommand{ + Name: "track", + Description: "Track words", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "add", + Description: "Add a tracked word", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "word", + Description: "The word to track", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "remove", + Description: "Remove a tracked word", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "word", + Description: "The tracked word to remove", + Required: true, + }, + }, + }, + }, +} + +var ( + trackCommandHandler = func(t track.Tracker, d *database.Database) func(*discordgo.Session, *discordgo.InteractionCreate) { + return func(s *discordgo.Session, i *discordgo.InteractionCreate) { + var action string + word := strings.ToLower(i.ApplicationCommandData().Options[0].Options[0].StringValue()) + switch i.ApplicationCommandData().Options[0].Name { + case "add": + action = "added" + t.Add(word, i.User.ID) + case "remove": + action = "removed" + t.Remove(word, i.User.ID) + default: + log.Error().Msg("Track command did a woopsie") + } + if err := d.SetTrack(word, t[word]); err != nil { + log.Err(err).Msg("") + } + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf("Successfully %s tracking for %q", action, word), + Flags: uint64(discordgo.MessageFlagsEphemeral), + }, + }); err != nil { + log.Err(err).Msg("") + } + } + } +) diff --git a/discord/track/track.go b/discord/track/track.go new file mode 100644 index 0000000..1f284d4 --- /dev/null +++ b/discord/track/track.go @@ -0,0 +1,52 @@ +package track + +import "strings" + +// Tracker is a mapping of tracked words to a set of user IDs +type Tracker map[string]map[string]struct{} + +// New returns an initialized Tracker +func New() Tracker { + return make(Tracker) +} + +// Add a user ID tracking a word +func (t Tracker) Add(word, userID string) { + if _, ok := t[word]; !ok { + t[word] = make(map[string]struct{}) + } + t[word][userID] = struct{}{} +} + +// Remove a user ID tracking a word +func (t Tracker) Remove(word, userID string) bool { + if _, ok := t[word][userID]; ok { + delete(t[word], userID) + return true + } + return false +} + +// Search for a tracked word and return the list of unique user IDs +func (t Tracker) Search(msg string) []string { + tokens := make(map[string]struct{}) + fields := strings.Fields(msg) + for _, field := range fields { + field = strings.Trim(field, `.,;:'"!?`) + tokens[field] = struct{}{} + } + + set := make(map[string]struct{}, 0) + for word, users := range t { + if _, ok := tokens[word]; ok { + for user := range users { + set[user] = struct{}{} + } + } + } + users := make([]string, 0, len(set)) + for user := range set { + users = append(users, user) + } + return users +} diff --git a/discord/track/track_test.go b/discord/track/track_test.go new file mode 100644 index 0000000..4734ca8 --- /dev/null +++ b/discord/track/track_test.go @@ -0,0 +1,27 @@ +package track + +import ( + "github.com/matryer/is" + "testing" +) + +func TestTracker(t *testing.T) { + assert := is.New(t) + + track := New() + + track.Add("admin", "foo") + track.Add("mod", "foo") + track.Add("mod", "bar") + track.Add("mode", "bar") + + admin := track.Search("I need an admin.") + assert.Equal(len(admin), 1) // One tracker for admin + + mod := track.Search("I need a mod. Please send me a mod.") + assert.Equal(len(mod), 2) // Two trackers for mod + + mode := track.Search("I'm in gaminga mode!") + assert.Equal(len(mode), 1) // One tracker for mode + +} diff --git a/go.mod b/go.mod index 1240496..f6e8b9b 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,14 @@ require ( git.jojodev.com/Minecraft/go-serverapi v0.0.1 gitea.com/jolheiser/gojang v0.0.7 gitea.com/jolheiser/xkcd v0.0.2 - github.com/bwmarrin/discordgo v0.22.0 + github.com/bwmarrin/discordgo v0.23.3-0.20220223175904-4cc53b7ed45c github.com/dghubble/go-twitter v0.0.0-20201011215211-4b180d0cc78d github.com/dghubble/oauth1 v0.7.0 - github.com/gorilla/websocket v1.4.2 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/matryer/is v1.4.0 // indirect github.com/pelletier/go-toml v1.8.1 github.com/rs/zerolog v1.26.1 go.etcd.io/bbolt v1.3.4 + golang.org/x/crypto v0.0.0-20220214200702-86341886e292 // indirect + golang.org/x/sys v0.0.0-20220224003255-dbe011f71a99 // indirect ) diff --git a/go.sum b/go.sum index 5dd3627..a1e80c0 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ gitea.com/jolheiser/xkcd v0.0.2 h1:HJP83YwSKxSYcoNfpb1ZpAfBvkUAnN+YgeukraXtfrc= gitea.com/jolheiser/xkcd v0.0.2/go.mod h1:aDa2vX54wLaX8Ra5CGN2GWBX13UWAGJKGGddzHl/hks= github.com/bwmarrin/discordgo v0.22.0 h1:uBxY1HmlVCsW1IuaPjpCGT6A2DBwRn0nvOguQIxDdFM= github.com/bwmarrin/discordgo v0.22.0/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M= +github.com/bwmarrin/discordgo v0.23.3-0.20220223175904-4cc53b7ed45c h1:efrWWIJhwYDtEhZsduYcJVtMD0m5qb/1X/5TExrsWXg= +github.com/bwmarrin/discordgo v0.23.3-0.20220223175904-4cc53b7ed45c/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/cenkalti/backoff v2.1.1+incompatible h1:tKJnvO2kl0zmb/jA5UKAt4VoEVw1qxKWjE/Bpp46npY= github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -26,6 +28,10 @@ github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM= github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -45,13 +51,17 @@ go.jolheiser.com/gql v0.0.1/go.mod h1:74eYqVRIxsOFxtVl0RYGKNyYQgJYQaxOCgar7LP71H golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e h1:1SzTfNOXwIS2oWiMF+6qu0OUDKb0dauo6MoDUQyu+yU= golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292 h1:f+lwQ+GtmgoY+A2YaQxlSOnDjXcQ7ZRLWOHbC6HtRqE= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -59,8 +69,11 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e h1:WUoyKPm6nCo1BnNUvPGnFG3T5DUVem42yDJZZ4CNxMA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220224003255-dbe011f71a99 h1:Us899Z5PCfOrSgeCYWobI1/bSigAz9Rhf8+fz5Grkzc= +golang.org/x/sys v0.0.0-20220224003255-dbe011f71a99/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=