diff --git a/action_core.go b/action_core.go index 21da8f7..2033826 100644 --- a/action_core.go +++ b/action_core.go @@ -8,6 +8,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/punish" "github.com/Luzifer/twitch-bot/internal/actors/raw" "github.com/Luzifer/twitch-bot/internal/actors/respond" "github.com/Luzifer/twitch-bot/internal/actors/timeout" @@ -23,6 +24,7 @@ var coreActorRegistations = []plugins.RegisterFunc{ delay.Register, deleteactor.Register, modchannel.Register, + punish.Register, raw.Register, respond.Register, timeout.Register, @@ -69,6 +71,7 @@ func getRegistrationArguments() plugins.RegistrationArguments { return plugins.RegistrationArguments{ FormatMessage: formatMessage, GetLogger: func(moduleName string) *log.Entry { return log.WithField("module", moduleName) }, + GetStorageManager: func() plugins.StorageManager { return store }, GetTwitchClient: func() *twitch.Client { return twitchClient }, RegisterActor: registerAction, RegisterActorDocumentation: registerActorDocumentation, diff --git a/internal/actors/punish/actor.go b/internal/actors/punish/actor.go new file mode 100644 index 0000000..7e59dc7 --- /dev/null +++ b/internal/actors/punish/actor.go @@ -0,0 +1,346 @@ +package punish + +import ( + "encoding/json" + "math" + "strconv" + "strings" + "sync" + "time" + + "github.com/Luzifer/twitch-bot/plugins" + "github.com/go-irc/irc" + "github.com/pkg/errors" +) + +const ( + actorNamePunish = "punish" + actorNameResetPunish = "reset-punish" + moduleUUID = "44ab4646-ce50-4e16-9353-c1f0eb68962b" + + oneWeek = 168 * time.Hour +) + +var ( + formatMessage plugins.MsgFormatter + ptrDefaultCooldown = func(v time.Duration) *time.Duration { return &v }(oneWeek) + ptrStringEmpty = func(v string) *string { return &v }("") + store plugins.StorageManager + storedObject = newStorage() +) + +func Register(args plugins.RegistrationArguments) error { + formatMessage = args.FormatMessage + store = args.GetStorageManager() + + args.RegisterActor(actorNamePunish, func() plugins.Actor { return &actorPunish{} }) + args.RegisterActor(actorNameResetPunish, func() plugins.Actor { return &actorResetPunish{} }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Apply increasing punishments to user", + Name: "Punish User", + Type: actorNamePunish, + + Fields: []plugins.ActionDocumentationField{ + { + Default: "168h", + Description: "When to lower the punishment level after the last punishment", + Key: "cooldown", + Name: "Cooldown", + Optional: true, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeDuration, + }, + { + Default: "", + Description: "Actions for each punishment level (ban, delete, duration-value i.e. 1m)", + Key: "levels", + Name: "Levels", + Optional: false, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeStringSlice, + }, + { + Default: "", + Description: "Reason why the user was banned / timeouted", + Key: "reason", + Name: "Reason", + Optional: true, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Default: "", + Description: "User to apply the action to", + Key: "user", + Name: "User", + Optional: false, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Default: "", + Description: "Unique identifier for this punishment to differentiate between punishments in the same channel", + Key: "uuid", + Name: "UUID", + Optional: true, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeString, + }, + }, + }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Reset punishment level for user", + Name: "Reset User Punishment", + Type: actorNameResetPunish, + + Fields: []plugins.ActionDocumentationField{ + { + Default: "", + Description: "User to reset the level for", + Key: "user", + Name: "User", + Optional: false, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Default: "", + Description: "Unique identifier for this punishment to differentiate between punishments in the same channel", + Key: "uuid", + Name: "UUID", + Optional: true, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeString, + }, + }, + }) + + return errors.Wrap( + store.GetModuleStore(moduleUUID, storedObject), + "loading module storage", + ) +} + +type ( + actorPunish struct{} + actorResetPunish struct{} + + levelConfig struct { + LastLevel int `json:"last_level"` + Executed time.Time `json:"executed"` + Cooldown time.Duration `json:"cooldown"` + } + + storage struct { + ActiveLevels map[string]*levelConfig `json:"active_levels"` + + lock sync.Mutex + } +) + +// Punish + +func (a actorPunish) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) { + var ( + cooldown = attrs.MustDuration("cooldown", ptrDefaultCooldown) + reason = attrs.MustString("reason", ptrStringEmpty) + user = attrs.MustString("user", nil) + uuid = attrs.MustString("uuid", ptrStringEmpty) + ) + + levels, err := attrs.StringSlice("levels") + if err != nil { + return false, errors.Wrap(err, "getting level config") + } + + if user, err = formatMessage(user, m, r, eventData); err != nil { + return false, errors.Wrap(err, "preparing user") + } + + lvl := storedObject.GetPunishment(plugins.DeriveChannel(m, eventData), user, uuid) + nLvl := int(math.Min(float64(len(levels)-1), float64(lvl.LastLevel+1))) + + var cmd []string + + switch lt := levels[nLvl]; lt { + case "ban": + cmd = []string{"/ban", strings.TrimLeft(user, "@")} + if reason != "" { + cmd = append(cmd, reason) + } + + case "delete": + msgID, ok := m.Tags.GetTag("id") + if !ok || msgID == "" { + return false, errors.New("found no mesage id") + } + + cmd = []string{"/delete", msgID} + + default: + to, err := time.ParseDuration(lt) + if err != nil { + return false, errors.Wrap(err, "parsing punishment level") + } + + cmd = []string{"/timeout", strings.TrimLeft(user, "@"), strconv.FormatInt(int64(to/time.Second), 10)} + if reason != "" { + cmd = append(cmd, reason) + } + } + + if err := c.WriteMessage(&irc.Message{ + Command: "PRIVMSG", + Params: []string{ + plugins.DeriveChannel(m, eventData), + strings.Join(cmd, " "), + }, + }); err != nil { + return false, errors.Wrap(err, "sending command") + } + + lvl.Cooldown = cooldown + lvl.Executed = time.Now() + lvl.LastLevel = nLvl + + return false, errors.Wrap( + store.SetModuleStore(moduleUUID, storedObject), + "storing punishment level", + ) +} + +func (a actorPunish) IsAsync() bool { return false } +func (a actorPunish) Name() string { return actorNamePunish } + +func (a actorPunish) Validate(attrs plugins.FieldCollection) (err error) { + if v, err := attrs.String("user"); err != nil || v == "" { + return errors.New("user must be non-empty string") + } + + if v, err := attrs.StringSlice("levels"); err != nil || len(v) == 0 { + return errors.New("levels must be slice of strings with length > 0") + } + + return nil +} + +// Reset + +func (a actorResetPunish) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) { + var ( + user = attrs.MustString("user", nil) + uuid = attrs.MustString("uuid", ptrStringEmpty) + ) + + if user, err = formatMessage(user, m, r, eventData); err != nil { + return false, errors.Wrap(err, "preparing user") + } + + storedObject.ResetLevel(plugins.DeriveChannel(m, eventData), user, uuid) + + return false, errors.Wrap( + store.SetModuleStore(moduleUUID, storedObject), + "resetting punishment level", + ) +} + +func (a actorResetPunish) IsAsync() bool { return false } +func (a actorResetPunish) Name() string { return actorNameResetPunish } + +func (a actorResetPunish) Validate(attrs plugins.FieldCollection) (err error) { + if v, err := attrs.String("user"); err != nil || v == "" { + return errors.New("user must be non-empty string") + } + + return nil +} + +// Storage + +func newStorage() *storage { + return &storage{ + ActiveLevels: make(map[string]*levelConfig), + } +} + +func (s *storage) GetPunishment(channel, user, uuid string) *levelConfig { + s.lock.Lock() + defer s.lock.Unlock() + + // Ensure old state is cleared + s.calculateCooldowns() + + var ( + id = s.getCacheKey(channel, user, uuid) + lvl = s.ActiveLevels[id] + ) + + if lvl == nil { + // Initialize a non-triggered state + lvl = &levelConfig{LastLevel: -1} + s.ActiveLevels[id] = lvl + } + + return lvl +} + +func (s *storage) ResetLevel(channel, user, uuid string) { + s.lock.Lock() + defer s.lock.Unlock() + + delete(s.ActiveLevels, s.getCacheKey(channel, user, uuid)) +} + +func (s *storage) getCacheKey(channel, user, uuid string) string { + return strings.Join([]string{channel, user, uuid}, "::") +} + +func (s *storage) calculateCooldowns() { + // This MUST NOT be locked, the lock MUST be set by calling method + + var clear []string + + for id, lvl := range s.ActiveLevels { + for { + cooldownTime := lvl.Executed.Add(lvl.Cooldown) + if cooldownTime.After(time.Now()) { + break + } + + lvl.Executed = cooldownTime + lvl.LastLevel-- + } + + // Level 0 is the first punishment level, so only remove if it drops below 0 + if lvl.LastLevel < 0 { + clear = append(clear, id) + } + } + + for _, id := range clear { + delete(s.ActiveLevels, id) + } +} + +// Implement marshaller interfaces +func (s *storage) MarshalStoredObject() ([]byte, error) { + s.lock.Lock() + defer s.lock.Unlock() + + s.calculateCooldowns() + return json.Marshal(s) +} + +func (s *storage) UnmarshalStoredObject(data []byte) error { + if data == nil { + // No data set yet, don't try to unmarshal + return nil + } + + s.lock.Lock() + defer s.lock.Unlock() + + return json.Unmarshal(data, s) +} diff --git a/main.go b/main.go index 9809792..9af5ef9 100644 --- a/main.go +++ b/main.go @@ -93,6 +93,10 @@ func main() { router.HandleFunc("/openapi.html", handleSwaggerHTML) router.HandleFunc("/openapi.json", handleSwaggerRequest) + if err = store.Load(); err != nil { + log.WithError(err).Fatal("Unable to load storage file") + } + if err = initCorePlugins(); err != nil { log.WithError(err).Fatal("Unable to load core plugins") } @@ -142,10 +146,6 @@ func main() { log.WithError(err).Fatal("Missing required parameters") } - if err = store.Load(); err != nil { - log.WithError(err).Fatal("Unable to load storage file") - } - fsEvents := make(chan configChangeEvent, 1) go watchConfigChanges(cfg.Config, fsEvents) diff --git a/plugins/interface.go b/plugins/interface.go index 694ae35..6d4cf82 100644 --- a/plugins/interface.go +++ b/plugins/interface.go @@ -47,6 +47,8 @@ type ( FormatMessage MsgFormatter // GetLogger returns a sirupsen log.Entry pre-configured with the module name GetLogger LoggerCreationFunc + // GetStorageManager returns an interface to access the modules storage + GetStorageManager func() StorageManager // GetTwitchClient retrieves a fully configured Twitch client with initialized cache GetTwitchClient func() *twitch.Client // RegisterActor is used to register a new IRC rule-actor implementing the Actor interface @@ -67,6 +69,20 @@ type ( SendMessageFunc func(*irc.Message) error + StorageManager interface { + DeleteModuleStore(moduleUUID string) error + GetModuleStore(moduleUUID string, storedObject StorageUnmarshaller) error + SetModuleStore(moduleUUID string, storedObject StorageMarshaller) error + } + + StorageMarshaller interface { + MarshalStoredObject() ([]byte, error) + } + + StorageUnmarshaller interface { + UnmarshalStoredObject([]byte) error + } + TemplateFuncGetter func(*irc.Message, *Rule, map[string]interface{}) interface{} TemplateFuncRegister func(name string, fg TemplateFuncGetter) ) diff --git a/store.go b/store.go index 0f4f1ad..e53564e 100644 --- a/store.go +++ b/store.go @@ -16,6 +16,8 @@ type storageFile struct { Timers map[string]plugins.TimerEntry `json:"timers"` Variables map[string]string `json:"variables"` + ModuleStorage map[string]json.RawMessage `json:"module_storage"` + inMem bool lock *sync.RWMutex } @@ -26,11 +28,22 @@ func newStorageFile(inMemStore bool) *storageFile { Timers: map[string]plugins.TimerEntry{}, Variables: map[string]string{}, + ModuleStorage: map[string]json.RawMessage{}, + inMem: inMemStore, lock: new(sync.RWMutex), } } +func (s *storageFile) DeleteModuleStore(moduleUUID string) error { + s.lock.Lock() + defer s.lock.Unlock() + + delete(s.ModuleStorage, moduleUUID) + + return errors.Wrap(s.Save(), "saving store") +} + func (s *storageFile) GetCounterValue(counter string) int64 { s.lock.RLock() defer s.lock.RUnlock() @@ -38,6 +51,16 @@ func (s *storageFile) GetCounterValue(counter string) int64 { return s.Counters[counter] } +func (s *storageFile) GetModuleStore(moduleUUID string, storedObject plugins.StorageUnmarshaller) error { + s.lock.RLock() + defer s.lock.RUnlock() + + return errors.Wrap( + storedObject.UnmarshalStoredObject(s.ModuleStorage[moduleUUID]), + "unmarshalling stored object", + ) +} + func (s *storageFile) GetVariable(key string) string { s.lock.RLock() defer s.lock.RUnlock() @@ -122,6 +145,20 @@ func (s *storageFile) Save() error { ) } +func (s *storageFile) SetModuleStore(moduleUUID string, storedObject plugins.StorageMarshaller) error { + s.lock.Lock() + defer s.lock.Unlock() + + data, err := storedObject.MarshalStoredObject() + if err != nil { + return errors.Wrap(err, "marshalling stored object") + } + + s.ModuleStorage[moduleUUID] = data + + return errors.Wrap(s.Save(), "saving store") +} + func (s *storageFile) SetTimer(kind plugins.TimerType, id string, expiry time.Time) error { s.lock.Lock() defer s.lock.Unlock() diff --git a/wiki/Actors.md b/wiki/Actors.md index ab971eb..af2efef 100644 --- a/wiki/Actors.md +++ b/wiki/Actors.md @@ -120,6 +120,52 @@ Modify variable contents set: "" ``` +## Punish User + +Apply increasing punishments to user + +```yaml +- type: punish + attributes: + # When to lower the punishment level after the last punishment + # Optional: true + # Type: duration + cooldown: 168h + # Actions for each punishment level (ban, delete, duration-value i.e. 1m) + # Optional: false + # Type: array of strings + levels: [] + # Reason why the user was banned / timeouted + # Optional: true + # Type: string + reason: "" + # User to apply the action to + # Optional: false + # Type: string (Supports Templating) + user: "" + # Unique identifier for this punishment to differentiate between punishments in the same channel + # Optional: true + # Type: string + uuid: "" +``` + +## Reset User Punishment + +Reset punishment level for user + +```yaml +- type: reset-punish + attributes: + # User to reset the level for + # Optional: false + # Type: string (Supports Templating) + user: "" + # Unique identifier for this punishment to differentiate between punishments in the same channel + # Optional: true + # Type: string + uuid: "" +``` + ## Respond to Message Respond to message with a new message