Implement proper anti-spam solution.

master
Icedream 2017-08-15 00:03:15 +02:00
parent 4565d7deaa
commit 872537f722
Signed by: icedream
GPG Key ID: 1573F6D8EFE4D0CF
6 changed files with 332 additions and 100 deletions

279
antispam/antispam.go Normal file
View File

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

16
antispam/antispam_test.go Normal file
View File

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

16
commandparser.go Normal file
View File

@ -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:],
}
}

29
main.go
View File

@ -12,12 +12,13 @@ import (
"net/http" "net/http"
"github.com/icedream/go-footballdata"
"github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter"
"github.com/thoj/go-ircevent" "github.com/thoj/go-ircevent"
"gopkg.in/alecthomas/kingpin.v2" "gopkg.in/alecthomas/kingpin.v2"
"github.com/icedream/embot/manager" "git.icedream.tech/icedream/soccer-bot/antispam"
"github.com/icedream/go-footballdata" "git.icedream.tech/icedream/soccer-bot/manager"
) )
const ( const (
@ -96,7 +97,6 @@ func main() {
m := manager.NewManager() m := manager.NewManager()
// IRC // IRC
//conn := m.AntifloodIrcConn(irc.IRC(nickname, ident))
conn := irc.IRC(nickname, ident) conn := irc.IRC(nickname, ident)
conn.Debug = debug conn.Debug = debug
conn.VerboseCallbackHandler = conn.Debug conn.VerboseCallbackHandler = conn.Debug
@ -109,6 +109,9 @@ func main() {
conn.PingFreq = pingFreq conn.PingFreq = pingFreq
} }
// Antispam
antispamInstance := antispam.New()
updateTopics := func() { updateTopics := func() {
// Get football data // Get football data
if r, err := footballData.FixturesOfSoccerSeason(Competition_Testing).Do(); err != nil { if r, err := footballData.FixturesOfSoccerSeason(Competition_Testing).Do(); err != nil {
@ -388,7 +391,7 @@ func main() {
}) })
conn.AddCallback("PRIVMSG", func(e *irc.Event) { conn.AddCallback("PRIVMSG", func(e *irc.Event) {
go func(event *irc.Event) { go func(event *irc.Event) {
//sender := event.Nick sender := event.Nick
target := event.Arguments[0] target := event.Arguments[0]
isChannel := true isChannel := true
if strings.EqualFold(target, conn.GetNick()) { 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) log.Printf("BUG - Emergency switch, caught message from bot to bot: %s", event.Arguments)
return return
} }
msg := stripIrcFormatting(event.Message())
msg := stripIrcFormatting(event.Message())
log.Printf("<%s @ %s> %s", event.Nick, target, msg) 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 { switch {
case !isChannel && strings.HasPrefix(msg, "updatetopics"):
case !isChannel && strings.EqualFold(cmd.Name, "updatetopics"):
updateTopicsChan <- nil updateTopicsChan <- nil
case strings.HasPrefix(msg, "!table"):
case strings.EqualFold(cmd.Name, "!table"):
if leagueTable, err := footballData.LeagueTableOfCompetition(Competition_Testing).Do(); err != nil { if leagueTable, err := footballData.LeagueTableOfCompetition(Competition_Testing).Do(); err != nil {
if s, err := tplString("error", err); err != nil { if s, err := tplString("error", err); err != nil {
log.Print(err) log.Print(err)
@ -461,7 +474,7 @@ func main() {
conn.Privmsg(target, line) 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 r, err := footballData.FixturesOfSoccerSeason(SoccerSeason_EuropeanChampionShipsFrance2016).TimeFrame(1 * day).Do(); err != nil {
if s, err := tplString("error", err); err != nil { if s, err := tplString("error", err); err != nil {
log.Print(err) log.Print(err)

View File

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

View File

@ -2,14 +2,9 @@ package manager
import ( import (
"sync" "sync"
"github.com/patrickmn/go-cache"
) )
type Manager struct { type Manager struct {
// antiflood variables
cache *cache.Cache
// topic variables // topic variables
topicStateLock sync.RWMutex topicStateLock sync.RWMutex
topicMap map[string]string topicMap map[string]string
@ -17,7 +12,6 @@ type Manager struct {
func NewManager() *Manager { func NewManager() *Manager {
m := new(Manager) m := new(Manager)
m.initAntiflood()
m.initTopic() m.initTopic()
return m return m
} }