Implement proper anti-spam solution.
parent
4565d7deaa
commit
872537f722
|
@ -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
|
||||
}
|
|
@ -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"))
|
||||
}
|
|
@ -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
29
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)
|
||||
|
|
|
@ -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...))
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue