diff --git a/antispam/antispam.go b/antispam/antispam.go new file mode 100644 index 0000000..b7292a2 --- /dev/null +++ b/antispam/antispam.go @@ -0,0 +1,279 @@ +package antispam + +import ( + "fmt" + "strings" + "sync" + "time" +) + +func normalizeUser(from string) string { + from = strings.ToLower(from) + + if strings.Contains(from, "!") { + split := strings.SplitN(from, "!", 2) + return fmt.Sprintf("*!%s", split[1]) + } + + return from +} + +func normalizeCommand(command string) string { + return strings.ToLower(command) +} + +type AntiSpam struct { + userTrackingMap map[string][]time.Time + userTrackingLock sync.RWMutex + + UserCooldownDuration time.Duration + + UserRateLimitDuration time.Duration + UserRateLimitCount int + + commandTrackingMap map[string][]time.Time + commandTrackingLock sync.RWMutex + + CommandCooldownDuration time.Duration + + CommandRateLimitDuration time.Duration + CommandRateLimitCount int +} + +func New() *AntiSpam { + as := new(AntiSpam) + as.init() + return as +} + +func (m *AntiSpam) init() { + m.userTrackingMap = map[string][]time.Time{} + m.commandTrackingMap = map[string][]time.Time{} + + m.CommandCooldownDuration = 15 * time.Second + m.CommandRateLimitCount = 2 + m.CommandRateLimitDuration = 1 * time.Minute + + m.UserCooldownDuration = 3 * time.Second + m.UserRateLimitCount = 4 + m.UserRateLimitDuration = 1 * time.Minute +} + +func (m *AntiSpam) checkUserCooldown(from string) (duration time.Duration) { + from = normalizeUser(from) + + m.userTrackingLock.RLock() + defer m.userTrackingLock.RUnlock() + + // Are there any times registered at all for this entry? + if timestamps := m.userTrackingMap[from]; timestamps != nil { + + var mostRecentTimestamp time.Time + + // Figure out most recent time + for _, timestamp := range timestamps { + if mostRecentTimestamp.Before(timestamp) { + mostRecentTimestamp = timestamp + } + } + + duration = mostRecentTimestamp.Add(m.UserCooldownDuration).Sub(time.Now()) + if duration < 0 { + duration = 0 + } + } + + return +} + +func (m *AntiSpam) checkCommandCooldown(command string) (duration time.Duration) { + command = normalizeCommand(command) + + m.commandTrackingLock.RLock() + defer m.commandTrackingLock.RUnlock() + + // Are there any times registered at all for this entry? + if timestamps := m.commandTrackingMap[command]; timestamps != nil { + + var mostRecentTimestamp time.Time + + // Figure out most recent time + for _, timestamp := range timestamps { + if mostRecentTimestamp.Before(timestamp) { + mostRecentTimestamp = timestamp + } + } + + duration = mostRecentTimestamp.Add(m.CommandCooldownDuration).Sub(time.Now()) + if duration < 0 { + duration = 0 + } + } + + return +} + +func (m *AntiSpam) checkUserRateLimit(from string) (duration time.Duration) { + from = normalizeUser(from) + + m.userTrackingLock.RLock() + defer m.userTrackingLock.RUnlock() + + // Are there any times registered at all for this entry? + if timestamps := m.userTrackingMap[from]; timestamps != nil && len(timestamps) >= m.UserRateLimitCount { + leastRecentTimestamp := timestamps[len(timestamps)-m.UserRateLimitCount] + + duration = leastRecentTimestamp.Add(m.UserRateLimitDuration).Sub(time.Now()) + if duration < 0 { + duration = 0 + } + } + + return +} + +func (m *AntiSpam) checkCommandRateLimit(command string) (duration time.Duration) { + command = normalizeCommand(command) + + m.commandTrackingLock.RLock() + defer m.commandTrackingLock.RUnlock() + + // Are there any times registered at all for this entry? + if timestamps := m.commandTrackingMap[command]; timestamps != nil && len(timestamps) >= m.CommandRateLimitCount { + leastRecentTimestamp := timestamps[len(timestamps)-m.CommandRateLimitCount] + + duration = leastRecentTimestamp.Add(m.CommandRateLimitDuration).Sub(time.Now()) + if duration < 0 { + duration = 0 + } + } + + return +} + +func (m *AntiSpam) track(from string, command string) { + currentTime := time.Now() + + command = normalizeCommand(command) + from = normalizeUser(from) + + // User tracking + go func() { + // Register current time + m.userTrackingLock.Lock() + if m.userTrackingMap[from] == nil { + m.userTrackingMap[from] = []time.Time{} + } + m.userTrackingMap[from] = append(m.userTrackingMap[from], currentTime) + m.userTrackingLock.Unlock() + + // Wait for time to run down + var waitDuration time.Duration + if m.UserCooldownDuration > m.UserRateLimitDuration { + waitDuration = m.UserCooldownDuration + } else { + waitDuration = m.UserRateLimitDuration + } + time.Sleep(waitDuration) + + // Remove current time + m.userTrackingLock.Lock() + indexToDelete := -1 + for i, time := range m.userTrackingMap[from] { + if time == currentTime { + indexToDelete = i + break + } + } + if indexToDelete >= 0 { + m.userTrackingMap[from] = append( + m.userTrackingMap[from][:indexToDelete], + m.userTrackingMap[from][indexToDelete+1:]...) + if len(m.userTrackingMap) == 0 { + m.userTrackingMap[from] = nil + } + } + m.userTrackingLock.Unlock() + }() + + // Command tracking + go func() { + // Register current time + m.commandTrackingLock.Lock() + if m.commandTrackingMap[command] == nil { + m.commandTrackingMap[command] = []time.Time{} + } + m.commandTrackingMap[command] = append(m.commandTrackingMap[command], currentTime) + m.commandTrackingLock.Unlock() + + // Wait for time to run down + var waitDuration time.Duration + if m.CommandCooldownDuration > m.CommandRateLimitDuration { + waitDuration = m.CommandCooldownDuration + } else { + waitDuration = m.CommandRateLimitDuration + } + time.Sleep(waitDuration) + + // Remove current time + m.commandTrackingLock.Lock() + indexToDelete := -1 + for i, time := range m.commandTrackingMap[command] { + if time == currentTime { + indexToDelete = i + break + } + } + if indexToDelete >= 0 { + m.commandTrackingMap[command] = append( + m.commandTrackingMap[command][:indexToDelete], + m.commandTrackingMap[command][indexToDelete+1:]...) + if len(m.commandTrackingMap) == 0 { + m.commandTrackingMap[command] = nil + } + } + m.commandTrackingLock.Unlock() + }() +} + +func (m *AntiSpam) Check(from string, command string) (allow bool, waitDuration time.Duration) { + allow = true + + // if this user has run a command in the last N seconds (user cooldown) + if currentDuration := m.checkUserCooldown(from); currentDuration > 0 { + allow = false + if currentDuration > waitDuration { + waitDuration = currentDuration + } + } + + // if this user has run X commands in the last N seconds (user rate limit) + if currentDuration := m.checkUserRateLimit(from); currentDuration > 0 { + allow = false + if currentDuration > waitDuration { + waitDuration = currentDuration + } + } + + // if command has been run just previously (command cooldown) + if currentDuration := m.checkCommandCooldown(command); currentDuration > 0 { + allow = false + if currentDuration > waitDuration { + waitDuration = currentDuration + } + } + + // if command has been run X (1?) times in the last N seconds (command rate limit) + if currentDuration := m.checkCommandRateLimit(command); currentDuration > 0 { + allow = false + if currentDuration > waitDuration { + waitDuration = currentDuration + } + } + + if allow { + m.track(from, command) + } + + return +} diff --git a/antispam/antispam_test.go b/antispam/antispam_test.go new file mode 100644 index 0000000..c2579f1 --- /dev/null +++ b/antispam/antispam_test.go @@ -0,0 +1,16 @@ +package antispam + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_normalizeUser(t *testing.T) { + require.Equal(t, normalizeUser("aBc!dEf@gHi"), normalizeUser("Abby!dEf@GhI")) + require.Equal(t, "*!ident@host", normalizeUser("nick!ident@host")) +} + +func Test_normalizeCommand(t *testing.T) { + require.Equal(t, normalizeCommand("!ping"), normalizeCommand("!PiNG")) +} diff --git a/commandparser.go b/commandparser.go new file mode 100644 index 0000000..3b6ece9 --- /dev/null +++ b/commandparser.go @@ -0,0 +1,16 @@ +package main + +import "strings" + +type Command struct { + Name string + Arguments []string +} + +func parseCommand(msg string) *Command { + split := strings.Split(msg, " ") + return &Command{ + Name: split[0], + Arguments: split[1:], + } +} diff --git a/main.go b/main.go index e44f742..0db5235 100644 --- a/main.go +++ b/main.go @@ -12,12 +12,13 @@ import ( "net/http" + "github.com/icedream/go-footballdata" "github.com/olekukonko/tablewriter" "github.com/thoj/go-ircevent" "gopkg.in/alecthomas/kingpin.v2" - "github.com/icedream/embot/manager" - "github.com/icedream/go-footballdata" + "git.icedream.tech/icedream/soccer-bot/antispam" + "git.icedream.tech/icedream/soccer-bot/manager" ) const ( @@ -96,7 +97,6 @@ func main() { m := manager.NewManager() // IRC - //conn := m.AntifloodIrcConn(irc.IRC(nickname, ident)) conn := irc.IRC(nickname, ident) conn.Debug = debug conn.VerboseCallbackHandler = conn.Debug @@ -109,6 +109,9 @@ func main() { conn.PingFreq = pingFreq } + // Antispam + antispamInstance := antispam.New() + updateTopics := func() { // Get football data if r, err := footballData.FixturesOfSoccerSeason(Competition_Testing).Do(); err != nil { @@ -388,7 +391,7 @@ func main() { }) conn.AddCallback("PRIVMSG", func(e *irc.Event) { go func(event *irc.Event) { - //sender := event.Nick + sender := event.Nick target := event.Arguments[0] isChannel := true if strings.EqualFold(target, conn.GetNick()) { @@ -402,13 +405,23 @@ func main() { log.Printf("BUG - Emergency switch, caught message from bot to bot: %s", event.Arguments) return } - msg := stripIrcFormatting(event.Message()) + msg := stripIrcFormatting(event.Message()) log.Printf("<%s @ %s> %s", event.Nick, target, msg) + + cmd := parseCommand(msg) + log.Printf("Antispam check: %s, %s", event.Source, cmd.Name) + if ok, duration := antispamInstance.Check(event.Source, cmd.Name); !ok { + conn.Noticef(sender, "Sorry, you need to wait %s before you can use this command again!", duration) + return + } + switch { - case !isChannel && strings.HasPrefix(msg, "updatetopics"): + + case !isChannel && strings.EqualFold(cmd.Name, "updatetopics"): updateTopicsChan <- nil - case strings.HasPrefix(msg, "!table"): + + case strings.EqualFold(cmd.Name, "!table"): if leagueTable, err := footballData.LeagueTableOfCompetition(Competition_Testing).Do(); err != nil { if s, err := tplString("error", err); err != nil { log.Print(err) @@ -461,7 +474,7 @@ func main() { conn.Privmsg(target, line) } } - case strings.HasPrefix(msg, "!match"): + case strings.EqualFold(cmd.Name, "!match"): /*if r, err := footballData.FixturesOfSoccerSeason(SoccerSeason_EuropeanChampionShipsFrance2016).TimeFrame(1 * day).Do(); err != nil { if s, err := tplString("error", err); err != nil { log.Print(err) diff --git a/manager/antiflood.go b/manager/antiflood.go deleted file mode 100644 index 75547a0..0000000 --- a/manager/antiflood.go +++ /dev/null @@ -1,86 +0,0 @@ -package manager - -import ( - "crypto/sha512" - "fmt" - "log" - "strings" - "time" - - "github.com/thoj/go-ircevent" - - cache "github.com/patrickmn/go-cache" -) - -func (m *Manager) initAntiflood() { - m.cache = cache.New(1*time.Minute, 5*time.Second) -} - -func (m *Manager) TrackOutput(target, t string) (shouldNotSend bool) { - key := normalizeTextAntiflood(target, t) - - if _, ok := m.cache.Get(key); ok { - // The URL has been used recently, should ignore - shouldNotSend = true - } else { - m.cache.Add(key, nil, cache.DefaultExpiration) - } - - return -} - -func (m *Manager) AntifloodIrcConn(c *irc.Connection) *ircConnectionProxy { - return &ircConnectionProxy{Connection: c, m: m} -} - -func normalizeTextAntiflood(target, text string) string { - s := sha512.New() - s.Write([]byte(text)) - return fmt.Sprintf("TEXT/%s/%X", strings.ToUpper(target), s.Sum([]byte{})) -} - -// Proxies several methods of the IRC connection in order to drop repeated messages -type ircConnectionProxy struct { - *irc.Connection - - m *Manager -} - -func (proxy *ircConnectionProxy) Action(target, message string) { - if proxy.m.TrackOutput(target, message) { - log.Printf("WARNING: Output antiflood triggered, dropping message for %s: %s", target, message) - return - } - - proxy.Connection.Action(target, message) -} - -func (proxy *ircConnectionProxy) Actionf(target, format string, a ...interface{}) { - proxy.Action(target, fmt.Sprintf(format, a...)) -} - -func (proxy *ircConnectionProxy) Privmsg(target, message string) { - if proxy.m.TrackOutput(target, message) { - log.Printf("WARNING: Output antiflood triggered, dropping message for %s: %s", target, message) - return - } - - proxy.Connection.Privmsg(target, message) -} - -func (proxy *ircConnectionProxy) Privmsgf(target, format string, a ...interface{}) { - proxy.Privmsg(target, fmt.Sprintf(format, a...)) -} - -func (proxy *ircConnectionProxy) Notice(target, message string) { - if proxy.m.TrackOutput(target, message) { - log.Printf("WARNING: Output antiflood triggered, dropping message for %s: %s", target, message) - return - } - - proxy.Connection.Notice(target, message) -} - -func (proxy *ircConnectionProxy) Noticef(target, format string, a ...interface{}) { - proxy.Notice(target, fmt.Sprintf(format, a...)) -} diff --git a/manager/manager.go b/manager/manager.go index 2b9e309..d2fef79 100644 --- a/manager/manager.go +++ b/manager/manager.go @@ -2,14 +2,9 @@ package manager import ( "sync" - - "github.com/patrickmn/go-cache" ) type Manager struct { - // antiflood variables - cache *cache.Cache - // topic variables topicStateLock sync.RWMutex topicMap map[string]string @@ -17,7 +12,6 @@ type Manager struct { func NewManager() *Manager { m := new(Manager) - m.initAntiflood() m.initTopic() return m }