twitch-bot/internal/actors/nuke/actor.go

247 lines
6.3 KiB
Go
Raw Normal View History

package nuke
import (
"regexp"
"strings"
"sync"
"time"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
const (
actorName = "nuke"
storeRetentionTime = 10 * time.Minute
)
var (
botTwitchClient *twitch.Client
formatMessage plugins.MsgFormatter
messageStore = map[string][]*storedMessage{}
messageStoreLock sync.RWMutex
ptrStringDelete = func(v string) *string { return &v }("delete")
ptrStringEmpty = func(s string) *string { return &s }("")
ptrString10m = func(v string) *string { return &v }("10m")
)
func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
args.RegisterActorDocumentation(plugins.ActionDocumentation{
Description: "Mass ban, delete, or timeout messages based on regex. Be sure you REALLY know what you do before using this! Used wrongly this will cause a lot of damage!",
Name: "Nuke Chat",
Type: actorName,
Fields: []plugins.ActionDocumentationField{
{
Default: "10m",
Description: "How long to scan into the past, template must yield a duration (max 10m)",
Key: "scan",
Name: "Scan-Duration",
Optional: true,
SupportTemplate: true,
Type: plugins.ActionDocumentationFieldTypeString,
},
{
Default: "delete",
Description: "What action to take when message matches (delete / ban / <timeout duration>)",
Key: "action",
Name: "Match-Action",
Optional: true,
SupportTemplate: true,
Type: plugins.ActionDocumentationFieldTypeString,
},
{
Default: "",
Description: "Regular expression (RE2) to select matching messages",
Key: "match",
Name: "Message-Match",
Optional: false,
SupportTemplate: true,
Type: plugins.ActionDocumentationFieldTypeString,
},
},
})
if _, err := args.RegisterCron("@every 1m", cleanupMessageStore); err != nil {
return errors.Wrap(err, "registering cleanup cron")
}
if err := args.RegisterRawMessageHandler(rawMessageHandler); err != nil {
return errors.Wrap(err, "registering raw message handler")
}
return nil
}
func cleanupMessageStore() {
messageStoreLock.Lock()
defer messageStoreLock.Unlock()
var storeDeletes []string
for ch, msgs := range messageStore {
var idx int
for idx = 0; idx < len(msgs); idx++ {
if time.Since(msgs[idx].Time) < storeRetentionTime {
break
}
}
newMsgs := msgs[idx:]
if len(newMsgs) == 0 {
storeDeletes = append(storeDeletes, ch)
continue
}
messageStore[ch] = newMsgs
log.WithFields(log.Fields{
"channel": ch,
"stored_messages": len(newMsgs),
}).Trace("[nuke] Cleared old stored messages")
}
for _, ch := range storeDeletes {
delete(messageStore, ch)
log.WithFields(log.Fields{
"channel": ch,
}).Trace("[nuke] Channel is no longer stored")
}
}
func rawMessageHandler(m *irc.Message) error {
if m.Command != "PRIVMSG" {
// We care only about user written messages and drop the rest
return nil
}
messageStoreLock.Lock()
defer messageStoreLock.Unlock()
messageStore[plugins.DeriveChannel(m, nil)] = append(
messageStore[plugins.DeriveChannel(m, nil)],
&storedMessage{Time: time.Now(), Msg: m},
)
return nil
}
type (
actor struct{}
storedMessage struct {
Time time.Time
Msg *irc.Message
}
)
func (a actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) {
rawMatch, err := formatMessage(attrs.MustString("match", nil), m, r, eventData)
if err != nil {
return false, errors.Wrap(err, "formatting match")
}
match := regexp.MustCompile(rawMatch)
rawScan, err := formatMessage(attrs.MustString("scan", ptrString10m), m, r, eventData)
if err != nil {
return false, errors.Wrap(err, "formatting scan duration")
}
scan, err := time.ParseDuration(rawScan)
if err != nil {
return false, errors.Wrap(err, "parsing scan duration")
}
scanTime := time.Now().Add(-scan)
var (
action actionFn
actionName string
)
rawAction, err := formatMessage(attrs.MustString("action", ptrStringDelete), m, r, eventData)
if err != nil {
return false, errors.Wrap(err, "formatting action")
}
switch rawAction {
case "delete":
action = actionDelete
actionName = "delete $msgid"
case "ban":
action = actionBan
actionName = "ban $user"
default:
to, err := time.ParseDuration(rawAction)
if err != nil {
return false, errors.Wrap(err, "parsing action duration")
}
action = getActionTimeout(to)
actionName = "timeout $user"
}
channel := plugins.DeriveChannel(m, eventData)
messageStoreLock.RLock()
defer messageStoreLock.RUnlock()
var executedEnforcement []string
for _, stMsg := range messageStore[channel] {
badges := twitch.ParseBadgeLevels(stMsg.Msg)
if stMsg.Time.Before(scanTime) {
continue
}
if badges.Has("broadcaster") || badges.Has("moderator") {
continue
}
if !match.MatchString(stMsg.Msg.Trailing()) {
continue
}
enforcement := strings.NewReplacer(
"$msgid", stMsg.Msg.Tags["id"],
"$user", plugins.DeriveUser(stMsg.Msg, nil),
).Replace(actionName)
if str.StringInSlice(enforcement, executedEnforcement) {
continue
}
if err = action(channel, rawMatch, stMsg.Msg.Tags["id"], plugins.DeriveUser(stMsg.Msg, nil)); err != nil {
return false, errors.Wrap(err, "executing action")
}
executedEnforcement = append(executedEnforcement, enforcement)
}
return false, nil
}
func (a actor) IsAsync() bool { return false }
func (a actor) Name() string { return actorName }
func (a actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) {
if v, err := attrs.String("match"); err != nil || v == "" {
return errors.New("match must be non-empty string")
}
for _, field := range []string{"scan", "action", "match"} {
if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil {
return errors.Wrapf(err, "validating %s template", field)
}
}
return nil
}