diff --git a/action_core.go b/action_core.go index c6db244..eab9aae 100644 --- a/action_core.go +++ b/action_core.go @@ -9,6 +9,7 @@ import ( "github.com/Luzifer/twitch-bot/internal/actors/delay" deleteactor "github.com/Luzifer/twitch-bot/internal/actors/delete" "github.com/Luzifer/twitch-bot/internal/actors/modchannel" + "github.com/Luzifer/twitch-bot/internal/actors/nuke" "github.com/Luzifer/twitch-bot/internal/actors/punish" "github.com/Luzifer/twitch-bot/internal/actors/quotedb" "github.com/Luzifer/twitch-bot/internal/actors/raw" @@ -27,6 +28,7 @@ var ( delay.Register, deleteactor.Register, modchannel.Register, + nuke.Register, punish.Register, quotedb.Register, raw.Register, diff --git a/internal/actors/nuke/actor.go b/internal/actors/nuke/actor.go new file mode 100644 index 0000000..5e4fcca --- /dev/null +++ b/internal/actors/nuke/actor.go @@ -0,0 +1,238 @@ +package nuke + +import ( + "fmt" + "regexp" + "strings" + "sync" + "time" + + "github.com/Luzifer/go_helpers/v2/str" + "github.com/Luzifer/twitch-bot/plugins" + "github.com/Luzifer/twitch-bot/twitch" + "github.com/go-irc/irc" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +const ( + actorName = "nuke" + storeRetentionTime = 10 * time.Minute +) + +var ( + formatMessage plugins.MsgFormatter + + messageStore = map[string][]*storedMessage{} + messageStoreLock sync.RWMutex + + ptrStringDelete = func(v string) *string { return &v }("delete") + ptrString10m = func(v string) *string { return &v }("10m") +) + +func Register(args plugins.RegistrationArguments) error { + 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 / )", + 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(c *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 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 = "/delete $msgid" + case "ban": + action = `/ban $user Nuke issued for "$match"` + default: + to, err := time.ParseDuration(rawAction) + if err != nil { + return false, errors.Wrap(err, "parsing action duration") + } + action = fmt.Sprintf(`/timeout $user %d Nuke issued for "$match"`, to/time.Second) + } + + 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( + "$match", rawMatch, + "$msgid", string(stMsg.Msg.Tags["id"]), + "$user", plugins.DeriveUser(stMsg.Msg, nil), + ).Replace(action) + + if str.StringInSlice(enforcement, executedEnforcement) { + continue + } + + if err = c.WriteMessage(&irc.Message{ + Command: "PRIVMSG", + Params: []string{ + channel, + enforcement, + }, + }); err != nil { + return false, errors.Wrap(err, "sending 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(attrs plugins.FieldCollection) (err error) { + if v, err := attrs.String("match"); err != nil || v == "" { + return errors.New("match must be non-empty string") + } + + return nil +} diff --git a/wiki/Actors.md b/wiki/Actors.md index 431b866..3fc7fae 100644 --- a/wiki/Actors.md +++ b/wiki/Actors.md @@ -120,6 +120,27 @@ Modify variable contents set: "" ``` +## Nuke Chat + +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! + +```yaml +- type: nuke + attributes: + # How long to scan into the past, template must yield a duration (max 10m) + # Optional: true + # Type: string (Supports Templating) + scan: "10m" + # What action to take when message matches (delete / ban / ) + # Optional: true + # Type: string (Supports Templating) + action: "delete" + # Regular expression (RE2) to select matching messages + # Optional: false + # Type: string (Supports Templating) + match: "" +``` + ## Punish User Apply increasing punishments to user