From ede8a95ed4b03a0f133a4f1e55b8a188d481e289 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Fri, 11 Jun 2021 13:52:42 +0200 Subject: [PATCH] Use more flexible Actor format to allow addition of new actors (#5) --- action_ban.go | 41 ++++++++----- action_counter.go | 69 ++++++++++++--------- action_delay.go | 36 +++++++---- action_delete.go | 51 +++++++++------- action_raw.go | 49 +++++++++------ action_respond.go | 87 ++++++++++++++------------ action_script.go | 141 +++++++++++++++++++++++-------------------- action_timeout.go | 41 ++++++++----- action_whisper.go | 72 ++++++++++++---------- actions.go | 48 ++++++++++++--- config.go | 8 +-- functions.go | 6 +- functions_counter.go | 2 +- functions_irc.go | 6 +- msgformatter.go | 2 +- rule.go | 93 +++++++++++++++------------- rule_test.go | 28 ++++----- 17 files changed, 453 insertions(+), 327 deletions(-) diff --git a/action_ban.go b/action_ban.go index a5a3343..7fef34d 100644 --- a/action_ban.go +++ b/action_ban.go @@ -8,20 +8,29 @@ import ( ) func init() { - registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { - if r.Ban == nil { - return nil - } - - return errors.Wrap( - c.WriteMessage(&irc.Message{ - Command: "PRIVMSG", - Params: []string{ - m.Params[0], - fmt.Sprintf("/ban %s %s", m.User, *r.Ban), - }, - }), - "sending timeout", - ) - }) + registerAction(func() Actor { return &ActorBan{} }) } + +type ActorBan struct { + Ban *string `json:"ban" yaml:"ban"` +} + +func (a ActorBan) Execute(c *irc.Client, m *irc.Message, r *Rule) error { + if a.Ban == nil { + return nil + } + + return errors.Wrap( + c.WriteMessage(&irc.Message{ + Command: "PRIVMSG", + Params: []string{ + m.Params[0], + fmt.Sprintf("/ban %s %s", m.User, *a.Ban), + }, + }), + "sending timeout", + ) +} + +func (a ActorBan) IsAsync() bool { return false } +func (a ActorBan) Name() string { return "ban" } diff --git a/action_counter.go b/action_counter.go index 18b7cb9..fd0ea5e 100644 --- a/action_counter.go +++ b/action_counter.go @@ -8,41 +8,52 @@ import ( ) func init() { - registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { - if r.Counter == nil { - return nil - } + registerAction(func() Actor { return &ActorCounter{} }) +} - counterName, err := formatMessage(*r.Counter, m, ruleDef, nil) +type ActorCounter struct { + CounterSet *string `json:"counter_set" yaml:"counter_set"` + CounterStep *int64 `json:"counter_step" yaml:"counter_step"` + Counter *string `json:"counter" yaml:"counter"` +} + +func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *Rule) error { + if a.Counter == nil { + return nil + } + + counterName, err := formatMessage(*a.Counter, m, r, nil) + if err != nil { + return errors.Wrap(err, "preparing response") + } + + if a.CounterSet != nil { + parseValue, err := formatMessage(*a.CounterSet, m, r, nil) if err != nil { - return errors.Wrap(err, "preparing response") + return errors.Wrap(err, "execute counter value template") } - if r.CounterSet != nil { - parseValue, err := formatMessage(*r.CounterSet, m, ruleDef, nil) - if err != nil { - return errors.Wrap(err, "execute counter value template") - } - - counterValue, err := strconv.ParseInt(parseValue, 10, 64) - if err != nil { - return errors.Wrap(err, "parse counter value") - } - - return errors.Wrap( - store.UpdateCounter(counterName, counterValue, true), - "set counter", - ) - } - - var counterStep int64 = 1 - if r.CounterStep != nil { - counterStep = *r.CounterStep + counterValue, err := strconv.ParseInt(parseValue, 10, 64) + if err != nil { + return errors.Wrap(err, "parse counter value") } return errors.Wrap( - store.UpdateCounter(counterName, counterStep, false), - "update counter", + store.UpdateCounter(counterName, counterValue, true), + "set counter", ) - }) + } + + var counterStep int64 = 1 + if a.CounterStep != nil { + counterStep = *a.CounterStep + } + + return errors.Wrap( + store.UpdateCounter(counterName, counterStep, false), + "update counter", + ) } + +func (a ActorCounter) IsAsync() bool { return false } +func (a ActorCounter) Name() string { return "counter" } diff --git a/action_delay.go b/action_delay.go index d9fcb7e..d8fef39 100644 --- a/action_delay.go +++ b/action_delay.go @@ -8,17 +8,27 @@ import ( ) func init() { - registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { - if r.Delay == 0 && r.DelayJitter == 0 { - return nil - } - - totalDelay := r.Delay - if r.DelayJitter > 0 { - totalDelay += time.Duration(rand.Int63n(int64(r.DelayJitter))) // #nosec: G404 // It's just time, no need for crypto/rand - } - - time.Sleep(totalDelay) - return nil - }) + registerAction(func() Actor { return &ActorDelay{} }) } + +type ActorDelay struct { + Delay time.Duration `json:"delay" yaml:"delay"` + DelayJitter time.Duration `json:"delay_jitter" yaml:"delay_jitter"` +} + +func (a ActorDelay) Execute(c *irc.Client, m *irc.Message, r *Rule) error { + if a.Delay == 0 && a.DelayJitter == 0 { + return nil + } + + totalDelay := a.Delay + if a.DelayJitter > 0 { + totalDelay += time.Duration(rand.Int63n(int64(a.DelayJitter))) // #nosec: G404 // It's just time, no need for crypto/rand + } + + time.Sleep(totalDelay) + return nil +} + +func (a ActorDelay) IsAsync() bool { return false } +func (a ActorDelay) Name() string { return "delay" } diff --git a/action_delete.go b/action_delete.go index 3cb99f5..2e4eb58 100644 --- a/action_delete.go +++ b/action_delete.go @@ -8,25 +8,34 @@ import ( ) func init() { - registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { - if r.DeleteMessage == nil || !*r.DeleteMessage { - return nil - } - - msgID, ok := m.Tags.GetTag("id") - if !ok || msgID == "" { - return nil - } - - return errors.Wrap( - c.WriteMessage(&irc.Message{ - Command: "PRIVMSG", - Params: []string{ - m.Params[0], - fmt.Sprintf("/delete %s", msgID), - }, - }), - "sending delete", - ) - }) + registerAction(func() Actor { return &ActorDelete{} }) } + +type ActorDelete struct { + DeleteMessage *bool `json:"delete_message" yaml:"delete_message"` +} + +func (a ActorDelete) Execute(c *irc.Client, m *irc.Message, r *Rule) error { + if a.DeleteMessage == nil || !*a.DeleteMessage { + return nil + } + + msgID, ok := m.Tags.GetTag("id") + if !ok || msgID == "" { + return nil + } + + return errors.Wrap( + c.WriteMessage(&irc.Message{ + Command: "PRIVMSG", + Params: []string{ + m.Params[0], + fmt.Sprintf("/delete %s", msgID), + }, + }), + "sending delete", + ) +} + +func (a ActorDelete) IsAsync() bool { return false } +func (a ActorDelete) Name() string { return "delete" } diff --git a/action_raw.go b/action_raw.go index 1f21ac6..1d4aff9 100644 --- a/action_raw.go +++ b/action_raw.go @@ -6,24 +6,33 @@ import ( ) func init() { - registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { - if r.RawMessage == nil { - return nil - } - - rawMsg, err := formatMessage(*r.RawMessage, m, ruleDef, nil) - if err != nil { - return errors.Wrap(err, "preparing raw message") - } - - msg, err := irc.ParseMessage(rawMsg) - if err != nil { - return errors.Wrap(err, "parsing raw message") - } - - return errors.Wrap( - c.WriteMessage(msg), - "sending raw message", - ) - }) + registerAction(func() Actor { return &ActorRaw{} }) } + +type ActorRaw struct { + RawMessage *string `json:"raw_message" yaml:"raw_message"` +} + +func (a ActorRaw) Execute(c *irc.Client, m *irc.Message, r *Rule) error { + if a.RawMessage == nil { + return nil + } + + rawMsg, err := formatMessage(*a.RawMessage, m, r, nil) + if err != nil { + return errors.Wrap(err, "preparing raw message") + } + + msg, err := irc.ParseMessage(rawMsg) + if err != nil { + return errors.Wrap(err, "parsing raw message") + } + + return errors.Wrap( + c.WriteMessage(msg), + "sending raw message", + ) +} + +func (a ActorRaw) IsAsync() bool { return false } +func (a ActorRaw) Name() string { return "raw" } diff --git a/action_respond.go b/action_respond.go index 0e36b66..f15a965 100644 --- a/action_respond.go +++ b/action_respond.go @@ -6,42 +6,53 @@ import ( ) func init() { - registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { - if r.Respond == nil { - return nil - } - - msg, err := formatMessage(*r.Respond, m, ruleDef, nil) - if err != nil { - if r.RespondFallback == nil { - return errors.Wrap(err, "preparing response") - } - if msg, err = formatMessage(*r.RespondFallback, m, ruleDef, nil); err != nil { - return errors.Wrap(err, "preparing response fallback") - } - } - - ircMessage := &irc.Message{ - Command: "PRIVMSG", - Params: []string{ - m.Params[0], - msg, - }, - } - - if r.RespondAsReply != nil && *r.RespondAsReply { - id, ok := m.GetTag("id") - if ok { - if ircMessage.Tags == nil { - ircMessage.Tags = make(irc.Tags) - } - ircMessage.Tags["reply-parent-msg-id"] = irc.TagValue(id) - } - } - - return errors.Wrap( - c.WriteMessage(ircMessage), - "sending response", - ) - }) + registerAction(func() Actor { return &ActorRespond{} }) } + +type ActorRespond struct { + Respond *string `json:"respond" yaml:"respond"` + RespondAsReply *bool `json:"respond_as_reply" yaml:"respond_as_reply"` + RespondFallback *string `json:"respond_fallback" yaml:"respond_fallback"` +} + +func (a ActorRespond) Execute(c *irc.Client, m *irc.Message, r *Rule) error { + if a.Respond == nil { + return nil + } + + msg, err := formatMessage(*a.Respond, m, r, nil) + if err != nil { + if a.RespondFallback == nil { + return errors.Wrap(err, "preparing response") + } + if msg, err = formatMessage(*a.RespondFallback, m, r, nil); err != nil { + return errors.Wrap(err, "preparing response fallback") + } + } + + ircMessage := &irc.Message{ + Command: "PRIVMSG", + Params: []string{ + m.Params[0], + msg, + }, + } + + if a.RespondAsReply != nil && *a.RespondAsReply { + id, ok := m.GetTag("id") + if ok { + if ircMessage.Tags == nil { + ircMessage.Tags = make(irc.Tags) + } + ircMessage.Tags["reply-parent-msg-id"] = irc.TagValue(id) + } + } + + return errors.Wrap( + c.WriteMessage(ircMessage), + "sending response", + ) +} + +func (a ActorRespond) IsAsync() bool { return false } +func (a ActorRespond) Name() string { return "respond" } diff --git a/action_script.go b/action_script.go index 32ba67c..43d4b87 100644 --- a/action_script.go +++ b/action_script.go @@ -12,70 +12,79 @@ import ( ) func init() { - registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { - if len(r.Command) == 0 { - return nil - } - - var command []string - for _, arg := range r.Command { - tmp, err := formatMessage(arg, m, ruleDef, nil) - if err != nil { - return errors.Wrap(err, "execute command argument template") - } - - command = append(command, tmp) - } - - ctx, cancel := context.WithTimeout(context.Background(), cfg.CommandTimeout) - defer cancel() - - var ( - stdin = new(bytes.Buffer) - stdout = new(bytes.Buffer) - ) - - if err := json.NewEncoder(stdin).Encode(map[string]interface{}{ - "badges": ircHandler{}.ParseBadgeLevels(m), - "channel": m.Params[0], - "message": m.Trailing(), - "tags": m.Tags, - "username": m.User, - }); err != nil { - return errors.Wrap(err, "encoding script input") - } - - cmd := exec.CommandContext(ctx, command[0], command[1:]...) // #nosec G204 // This is expected to call a command with parameters - cmd.Env = os.Environ() - cmd.Stderr = os.Stderr - cmd.Stdin = stdin - cmd.Stdout = stdout - - if err := cmd.Run(); err != nil { - return errors.Wrap(err, "running command") - } - - if stdout.Len() == 0 { - // Script was successful but did not yield actions - return nil - } - - var ( - actions []*ruleAction - decoder = json.NewDecoder(stdout) - ) - - decoder.DisallowUnknownFields() - if err := decoder.Decode(&actions); err != nil { - return errors.Wrap(err, "decoding actions output") - } - - for _, action := range actions { - if err := triggerActions(c, m, ruleDef, action); err != nil { - return errors.Wrap(err, "execute returned action") - } - } - - return nil - }) + registerAction(func() Actor { return &ActorScript{} }) } + +type ActorScript struct { + Command []string `json:"command" yaml:"command"` +} + +func (a ActorScript) Execute(c *irc.Client, m *irc.Message, r *Rule) error { + if len(a.Command) == 0 { + return nil + } + + var command []string + for _, arg := range a.Command { + tmp, err := formatMessage(arg, m, r, nil) + if err != nil { + return errors.Wrap(err, "execute command argument template") + } + + command = append(command, tmp) + } + + ctx, cancel := context.WithTimeout(context.Background(), cfg.CommandTimeout) + defer cancel() + + var ( + stdin = new(bytes.Buffer) + stdout = new(bytes.Buffer) + ) + + if err := json.NewEncoder(stdin).Encode(map[string]interface{}{ + "badges": ircHandler{}.ParseBadgeLevels(m), + "channel": m.Params[0], + "message": m.Trailing(), + "tags": m.Tags, + "username": m.User, + }); err != nil { + return errors.Wrap(err, "encoding script input") + } + + cmd := exec.CommandContext(ctx, command[0], command[1:]...) // #nosec G204 // This is expected to call a command with parameters + cmd.Env = os.Environ() + cmd.Stderr = os.Stderr + cmd.Stdin = stdin + cmd.Stdout = stdout + + if err := cmd.Run(); err != nil { + return errors.Wrap(err, "running command") + } + + if stdout.Len() == 0 { + // Script was successful but did not yield actions + return nil + } + + var ( + actions []*RuleAction + decoder = json.NewDecoder(stdout) + ) + + decoder.DisallowUnknownFields() + if err := decoder.Decode(&actions); err != nil { + return errors.Wrap(err, "decoding actions output") + } + + for _, action := range actions { + if err := triggerActions(c, m, r, action); err != nil { + return errors.Wrap(err, "execute returned action") + } + } + + return nil +} + +func (a ActorScript) IsAsync() bool { return false } +func (a ActorScript) Name() string { return "script" } diff --git a/action_timeout.go b/action_timeout.go index 834473b..6a11cdd 100644 --- a/action_timeout.go +++ b/action_timeout.go @@ -9,20 +9,29 @@ import ( ) func init() { - registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { - if r.Timeout == nil { - return nil - } - - return errors.Wrap( - c.WriteMessage(&irc.Message{ - Command: "PRIVMSG", - Params: []string{ - m.Params[0], - fmt.Sprintf("/timeout %s %d", m.User, *r.Timeout/time.Second), - }, - }), - "sending timeout", - ) - }) + registerAction(func() Actor { return &ActorTimeout{} }) } + +type ActorTimeout struct { + Timeout *time.Duration `json:"timeout" yaml:"timeout"` +} + +func (a ActorTimeout) Execute(c *irc.Client, m *irc.Message, r *Rule) error { + if a.Timeout == nil { + return nil + } + + return errors.Wrap( + c.WriteMessage(&irc.Message{ + Command: "PRIVMSG", + Params: []string{ + m.Params[0], + fmt.Sprintf("/timeout %s %d", m.User, *a.Timeout/time.Second), + }, + }), + "sending timeout", + ) +} + +func (a ActorTimeout) IsAsync() bool { return false } +func (a ActorTimeout) Name() string { return "timeout" } diff --git a/action_whisper.go b/action_whisper.go index 8dc9550..a1ecf77 100644 --- a/action_whisper.go +++ b/action_whisper.go @@ -8,35 +8,45 @@ import ( ) func init() { - registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { - if r.WhisperTo == nil || r.WhisperMessage == nil { - return nil - } - - to, err := formatMessage(*r.WhisperTo, m, ruleDef, nil) - if err != nil { - return errors.Wrap(err, "preparing whisper receiver") - } - - msg, err := formatMessage(*r.WhisperMessage, m, ruleDef, nil) - if err != nil { - return errors.Wrap(err, "preparing whisper message") - } - - channel := "#tmijs" // As a fallback, copied from tmi.js - if len(config.Channels) > 0 { - channel = fmt.Sprintf("#%s", config.Channels[0]) - } - - return errors.Wrap( - c.WriteMessage(&irc.Message{ - Command: "PRIVMSG", - Params: []string{ - channel, - fmt.Sprintf("/w %s %s", to, msg), - }, - }), - "sending whisper", - ) - }) + registerAction(func() Actor { return &ActorWhisper{} }) } + +type ActorWhisper struct { + WhisperMessage *string `json:"whisper_message" yaml:"whisper_message"` + WhisperTo *string `json:"whisper_to" yaml:"whisper_to"` +} + +func (a ActorWhisper) Execute(c *irc.Client, m *irc.Message, r *Rule) error { + if a.WhisperTo == nil || a.WhisperMessage == nil { + return nil + } + + to, err := formatMessage(*a.WhisperTo, m, r, nil) + if err != nil { + return errors.Wrap(err, "preparing whisper receiver") + } + + msg, err := formatMessage(*a.WhisperMessage, m, r, nil) + if err != nil { + return errors.Wrap(err, "preparing whisper message") + } + + channel := "#tmijs" // As a fallback, copied from tmi.js + if len(config.Channels) > 0 { + channel = fmt.Sprintf("#%s", config.Channels[0]) + } + + return errors.Wrap( + c.WriteMessage(&irc.Message{ + Command: "PRIVMSG", + Params: []string{ + channel, + fmt.Sprintf("/w %s %s", to, msg), + }, + }), + "sending whisper", + ) +} + +func (a ActorWhisper) IsAsync() bool { return false } +func (a ActorWhisper) Name() string { return "whisper" } diff --git a/actions.go b/actions.go index 49dff28..bad6eea 100644 --- a/actions.go +++ b/actions.go @@ -8,26 +8,58 @@ import ( log "github.com/sirupsen/logrus" ) +type ( + Actor interface { + // Execute will be called after the config was read into the Actor + Execute(*irc.Client, *irc.Message, *Rule) error + // IsAsync may return true if the Execute function is to be executed + // in a Go routine as of long runtime. Normally it should return false + // except in very specific cases + IsAsync() bool + // Name must return an unique name for the actor in order to identify + // it in the logs for debugging purposes + Name() string + } + ActorCreationFunc func() Actor +) + var ( - availableActions []actionFunc + availableActions []ActorCreationFunc availableActionsLock = new(sync.RWMutex) ) -type actionFunc func(*irc.Client, *irc.Message, *rule, *ruleAction) error - -func registerAction(af actionFunc) { +func registerAction(af ActorCreationFunc) { availableActionsLock.Lock() defer availableActionsLock.Unlock() availableActions = append(availableActions, af) } -func triggerActions(c *irc.Client, m *irc.Message, rule *rule, ra *ruleAction) error { +func triggerActions(c *irc.Client, m *irc.Message, rule *Rule, ra *RuleAction) error { availableActionsLock.RLock() defer availableActionsLock.RUnlock() - for _, af := range availableActions { - if err := af(c, m, rule, ra); err != nil { + for _, acf := range availableActions { + var ( + a = acf() + logger = log.WithField("actor", a.Name()) + ) + + if err := ra.Unmarshal(a); err != nil { + logger.WithError(err).Trace("Unable to unmarshal config") + continue + } + + if a.IsAsync() { + go func() { + if err := a.Execute(c, m, rule); err != nil { + logger.WithError(err).Error("Error in async actor") + } + }() + continue + } + + if err := a.Execute(c, m, rule); err != nil { return errors.Wrap(err, "execute action") } } @@ -44,6 +76,6 @@ func handleMessage(c *irc.Client, m *irc.Message, event *string) { } // Lock command - r.SetCooldown(m) + r.setCooldown(m) } } diff --git a/config.go b/config.go index 55e970e..2bed3bd 100644 --- a/config.go +++ b/config.go @@ -19,7 +19,7 @@ type configFile struct { PermitAllowModerator bool `yaml:"permit_allow_moderator"` PermitTimeout time.Duration `yaml:"permit_timeout"` RawLog string `yaml:"raw_log"` - Rules []*rule `yaml:"rules"` + Rules []*Rule `yaml:"rules"` Variables map[string]interface{} `yaml:"variables"` rawLogWriter io.WriteCloser @@ -125,14 +125,14 @@ func (c *configFile) CloseRawMessageWriter() error { return c.rawLogWriter.Close() } -func (c configFile) GetMatchingRules(m *irc.Message, event *string) []*rule { +func (c configFile) GetMatchingRules(m *irc.Message, event *string) []*Rule { configLock.RLock() defer configLock.RUnlock() - var out []*rule + var out []*Rule for _, r := range c.Rules { - if r.Matches(m, event) { + if r.matches(m, event) { out = append(out, r) } } diff --git a/functions.go b/functions.go index 9ab6b86..99d990f 100644 --- a/functions.go +++ b/functions.go @@ -12,7 +12,7 @@ import ( var tplFuncs = newTemplateFuncProvider() type ( - templateFuncGetter func(*irc.Message, *rule, map[string]interface{}) interface{} + templateFuncGetter func(*irc.Message, *Rule, map[string]interface{}) interface{} templateFuncProvider struct { funcs map[string]templateFuncGetter lock *sync.RWMutex @@ -28,7 +28,7 @@ func newTemplateFuncProvider() *templateFuncProvider { return out } -func (t *templateFuncProvider) GetFuncMap(m *irc.Message, r *rule, fields map[string]interface{}) template.FuncMap { +func (t *templateFuncProvider) GetFuncMap(m *irc.Message, r *Rule, fields map[string]interface{}) template.FuncMap { t.lock.RLock() defer t.lock.RUnlock() @@ -49,7 +49,7 @@ func (t *templateFuncProvider) Register(name string, fg templateFuncGetter) { } func genericTemplateFunctionGetter(f interface{}) templateFuncGetter { - return func(*irc.Message, *rule, map[string]interface{}) interface{} { return f } + return func(*irc.Message, *Rule, map[string]interface{}) interface{} { return f } } func init() { diff --git a/functions_counter.go b/functions_counter.go index 678ff2c..ddbb694 100644 --- a/functions_counter.go +++ b/functions_counter.go @@ -8,7 +8,7 @@ import ( ) func init() { - tplFuncs.Register("channelCounter", func(m *irc.Message, r *rule, fields map[string]interface{}) interface{} { + tplFuncs.Register("channelCounter", func(m *irc.Message, r *Rule, fields map[string]interface{}) interface{} { return func(name string) (string, error) { channel, ok := fields["channel"].(string) if !ok { diff --git a/functions_irc.go b/functions_irc.go index 798d531..23a0a5a 100644 --- a/functions_irc.go +++ b/functions_irc.go @@ -8,7 +8,7 @@ import ( ) func init() { - tplFuncs.Register("arg", func(m *irc.Message, r *rule, fields map[string]interface{}) interface{} { + tplFuncs.Register("arg", func(m *irc.Message, r *Rule, fields map[string]interface{}) interface{} { return func(arg int) (string, error) { msgParts := strings.Split(m.Trailing(), " ") if len(msgParts) <= arg { @@ -21,7 +21,7 @@ func init() { tplFuncs.Register("fixUsername", genericTemplateFunctionGetter(func(username string) string { return strings.TrimLeft(username, "@#") })) - tplFuncs.Register("group", func(m *irc.Message, r *rule, fields map[string]interface{}) interface{} { + tplFuncs.Register("group", func(m *irc.Message, r *Rule, fields map[string]interface{}) interface{} { return func(idx int) (string, error) { fields := r.matchMessage.FindStringSubmatch(m.Trailing()) if len(fields) <= idx { @@ -32,7 +32,7 @@ func init() { } }) - tplFuncs.Register("tag", func(m *irc.Message, r *rule, fields map[string]interface{}) interface{} { + tplFuncs.Register("tag", func(m *irc.Message, r *Rule, fields map[string]interface{}) interface{} { return func(tag string) string { s, _ := m.GetTag(tag) return s diff --git a/msgformatter.go b/msgformatter.go index 5059376..7769aab 100644 --- a/msgformatter.go +++ b/msgformatter.go @@ -9,7 +9,7 @@ import ( "github.com/pkg/errors" ) -func formatMessage(tplString string, m *irc.Message, r *rule, fields map[string]interface{}) (string, error) { +func formatMessage(tplString string, m *irc.Message, r *Rule, fields map[string]interface{}) (string, error) { compiledFields := map[string]interface{}{} if config != nil { diff --git a/rule.go b/rule.go index a9941e5..592aafa 100644 --- a/rule.go +++ b/rule.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "crypto/sha256" + "encoding/json" "fmt" "regexp" "strings" @@ -9,11 +11,12 @@ import ( "github.com/Luzifer/go_helpers/v2/str" "github.com/go-irc/irc" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) -type rule struct { - Actions []*ruleAction `yaml:"actions"` +type Rule struct { + Actions []*RuleAction `yaml:"actions"` Cooldown *time.Duration `yaml:"cooldown"` ChannelCooldown *time.Duration `yaml:"channel_cooldown"` @@ -38,7 +41,7 @@ type rule struct { disableOnMatchMessages []*regexp.Regexp } -func (r rule) MatcherID() string { +func (r Rule) MatcherID() string { out := sha256.New() for _, e := range []*string{ @@ -54,7 +57,7 @@ func (r rule) MatcherID() string { return fmt.Sprintf("sha256:%x", out.Sum(nil)) } -func (r *rule) Matches(m *irc.Message, event *string) bool { +func (r *Rule) matches(m *irc.Message, event *string) bool { var ( badges = ircHandler{}.ParseBadgeLevels(m) logger = log.WithFields(log.Fields{ @@ -88,7 +91,7 @@ func (r *rule) Matches(m *irc.Message, event *string) bool { return true } -func (r *rule) SetCooldown(m *irc.Message) { +func (r *Rule) setCooldown(m *irc.Message) { if r.Cooldown != nil { timerStore.AddCooldown(timerTypeCooldown, "", r.MatcherID()) } @@ -102,7 +105,7 @@ func (r *rule) SetCooldown(m *irc.Message) { } } -func (r *rule) allowExecuteBadgeBlacklist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteBadgeBlacklist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { for _, b := range r.DisableOn { if badges.Has(b) { logger.Tracef("Non-Match: Disable-Badge %s", b) @@ -113,7 +116,7 @@ func (r *rule) allowExecuteBadgeBlacklist(logger *log.Entry, m *irc.Message, eve return true } -func (r *rule) allowExecuteBadgeWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteBadgeWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { if len(r.EnableOn) == 0 { // No match criteria set, does not speak against matching return true @@ -128,7 +131,7 @@ func (r *rule) allowExecuteBadgeWhitelist(logger *log.Entry, m *irc.Message, eve return false } -func (r *rule) allowExecuteChannelCooldown(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteChannelCooldown(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { if r.ChannelCooldown == nil || len(m.Params) < 1 { // No match criteria set, does not speak against matching return true @@ -147,7 +150,7 @@ func (r *rule) allowExecuteChannelCooldown(logger *log.Entry, m *irc.Message, ev return false } -func (r *rule) allowExecuteChannelWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteChannelWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { if len(r.MatchChannels) == 0 { // No match criteria set, does not speak against matching return true @@ -161,7 +164,7 @@ func (r *rule) allowExecuteChannelWhitelist(logger *log.Entry, m *irc.Message, e return true } -func (r *rule) allowExecuteDisable(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteDisable(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { if r.Disable == nil { // No match criteria set, does not speak against matching return true @@ -175,7 +178,7 @@ func (r *rule) allowExecuteDisable(logger *log.Entry, m *irc.Message, event *str return true } -func (r *rule) allowExecuteDisableOnOffline(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteDisableOnOffline(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { if r.DisableOnOffline == nil || !*r.DisableOnOffline { // No match criteria set, does not speak against matching return true @@ -194,7 +197,7 @@ func (r *rule) allowExecuteDisableOnOffline(logger *log.Entry, m *irc.Message, e return true } -func (r *rule) allowExecuteDisableOnPermit(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteDisableOnPermit(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { if r.DisableOnPermit != nil && *r.DisableOnPermit && timerStore.HasPermit(m.Params[0], m.User) { logger.Trace("Non-Match: Permit") return false @@ -203,7 +206,7 @@ func (r *rule) allowExecuteDisableOnPermit(logger *log.Entry, m *irc.Message, ev return true } -func (r *rule) allowExecuteDisableOnTemplate(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteDisableOnTemplate(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { if r.DisableOnTemplate == nil { // No match criteria set, does not speak against matching return true @@ -224,7 +227,7 @@ func (r *rule) allowExecuteDisableOnTemplate(logger *log.Entry, m *irc.Message, return true } -func (r *rule) allowExecuteEventWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteEventWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { if r.MatchEvent == nil { // No match criteria set, does not speak against matching return true @@ -238,7 +241,7 @@ func (r *rule) allowExecuteEventWhitelist(logger *log.Entry, m *irc.Message, eve return true } -func (r *rule) allowExecuteMessageMatcherBlacklist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteMessageMatcherBlacklist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { if len(r.DisableOnMatchMessages) == 0 { // No match criteria set, does not speak against matching return true @@ -267,7 +270,7 @@ func (r *rule) allowExecuteMessageMatcherBlacklist(logger *log.Entry, m *irc.Mes return true } -func (r *rule) allowExecuteMessageMatcherWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteMessageMatcherWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { if r.MatchMessage == nil { // No match criteria set, does not speak against matching return true @@ -292,7 +295,7 @@ func (r *rule) allowExecuteMessageMatcherWhitelist(logger *log.Entry, m *irc.Mes return true } -func (r *rule) allowExecuteRuleCooldown(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteRuleCooldown(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { if r.Cooldown == nil { // No match criteria set, does not speak against matching return true @@ -311,7 +314,7 @@ func (r *rule) allowExecuteRuleCooldown(logger *log.Entry, m *irc.Message, event return false } -func (r *rule) allowExecuteUserCooldown(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteUserCooldown(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { if r.UserCooldown == nil { // No match criteria set, does not speak against matching return true @@ -330,7 +333,7 @@ func (r *rule) allowExecuteUserCooldown(logger *log.Entry, m *irc.Message, event return false } -func (r *rule) allowExecuteUserWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { +func (r *Rule) allowExecuteUserWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool { if len(r.MatchUsers) == 0 { // No match criteria set, does not speak against matching return true @@ -344,28 +347,32 @@ func (r *rule) allowExecuteUserWhitelist(logger *log.Entry, m *irc.Message, even return true } -type ruleAction struct { - Ban *string `json:"ban" yaml:"ban"` - - Command []string `json:"command" yaml:"command"` - - CounterSet *string `json:"counter_set" yaml:"counter_set"` - CounterStep *int64 `json:"counter_step" yaml:"counter_step"` - Counter *string `json:"counter" yaml:"counter"` - - Delay time.Duration `json:"delay" yaml:"delay"` - DelayJitter time.Duration `json:"delay_jitter" yaml:"delay_jitter"` - - DeleteMessage *bool `json:"delete_message" yaml:"delete_message"` - - RawMessage *string `json:"raw_message" yaml:"raw_message"` - - Respond *string `json:"respond" yaml:"respond"` - RespondAsReply *bool `json:"respond_as_reply" yaml:"respond_as_reply"` - RespondFallback *string `json:"respond_fallback" yaml:"respond_fallback"` - - Timeout *time.Duration `json:"timeout" yaml:"timeout"` - - WhisperMessage *string `json:"whisper_message" yaml:"whisper_message"` - WhisperTo *string `json:"whisper_to" yaml:"whisper_to"` +type RuleAction struct { + yamlUnmarshal func(interface{}) error + jsonValue []byte +} + +func (r *RuleAction) UnmarshalJSON(d []byte) error { + r.jsonValue = d + return nil +} + +func (r *RuleAction) UnmarshalYAML(unmarshal func(interface{}) error) error { + r.yamlUnmarshal = unmarshal + return nil +} + +func (r *RuleAction) Unmarshal(v interface{}) error { + switch { + case r.yamlUnmarshal != nil: + return r.yamlUnmarshal(v) + + case r.jsonValue != nil: + jd := json.NewDecoder(bytes.NewReader(r.jsonValue)) + jd.DisallowUnknownFields() + return jd.Decode(v) + + default: + return errors.New("unmarshal on unprimed object") + } } diff --git a/rule_test.go b/rule_test.go index 16bcb11..854a6af 100644 --- a/rule_test.go +++ b/rule_test.go @@ -16,7 +16,7 @@ var ( ) func TestAllowExecuteBadgeBlacklist(t *testing.T) { - r := &rule{DisableOn: []string{badgeBroadcaster}} + r := &Rule{DisableOn: []string{badgeBroadcaster}} if r.allowExecuteBadgeBlacklist(testLogger, nil, nil, badgeCollection{badgeBroadcaster: testBadgeLevel0}) { t.Error("Execution allowed on blacklisted badge") @@ -28,7 +28,7 @@ func TestAllowExecuteBadgeBlacklist(t *testing.T) { } func TestAllowExecuteBadgeWhitelist(t *testing.T) { - r := &rule{EnableOn: []string{badgeBroadcaster}} + r := &Rule{EnableOn: []string{badgeBroadcaster}} if r.allowExecuteBadgeWhitelist(testLogger, nil, nil, badgeCollection{badgeModerator: testBadgeLevel0}) { t.Error("Execution allowed without whitelisted badge") @@ -40,7 +40,7 @@ func TestAllowExecuteBadgeWhitelist(t *testing.T) { } func TestAllowExecuteChannelWhitelist(t *testing.T) { - r := &rule{MatchChannels: []string{"#mychannel", "otherchannel"}} + r := &Rule{MatchChannels: []string{"#mychannel", "otherchannel"}} for m, exp := range map[string]bool{ ":amy!amy@foo.example.com PRIVMSG #mychannel :Testing": true, @@ -59,7 +59,7 @@ func TestAllowExecuteChannelWhitelist(t *testing.T) { } func TestAllowExecuteDisable(t *testing.T) { - for exp, r := range map[bool]*rule{ + for exp, r := range map[bool]*Rule{ true: {Disable: testPtrBool(false)}, false: {Disable: testPtrBool(true)}, } { @@ -70,7 +70,7 @@ func TestAllowExecuteDisable(t *testing.T) { } func TestAllowExecuteDisableOnOffline(t *testing.T) { - r := &rule{DisableOnOffline: testPtrBool(true)} + r := &Rule{DisableOnOffline: testPtrBool(true)} // Fake cache entries to prevent calling the real Twitch API twitch.apiCache.Set([]string{"hasLiveStream", "channel1"}, time.Minute, true) @@ -87,7 +87,7 @@ func TestAllowExecuteDisableOnOffline(t *testing.T) { } func TestAllowExecuteChannelCooldown(t *testing.T) { - r := &rule{ChannelCooldown: func(i time.Duration) *time.Duration { return &i }(time.Minute), SkipCooldownFor: []string{badgeBroadcaster}} + r := &Rule{ChannelCooldown: func(i time.Duration) *time.Duration { return &i }(time.Minute), SkipCooldownFor: []string{badgeBroadcaster}} c1 := irc.MustParseMessage(":amy!amy@foo.example.com PRIVMSG #mychannel :Testing") c2 := irc.MustParseMessage(":amy!amy@foo.example.com PRIVMSG #otherchannel :Testing") @@ -112,7 +112,7 @@ func TestAllowExecuteChannelCooldown(t *testing.T) { } func TestAllowExecuteDisableOnPermit(t *testing.T) { - r := &rule{DisableOnPermit: testPtrBool(true)} + r := &Rule{DisableOnPermit: testPtrBool(true)} // Permit is using global configuration, so we must fake that one config = &configFile{PermitTimeout: time.Minute} @@ -130,7 +130,7 @@ func TestAllowExecuteDisableOnPermit(t *testing.T) { } func TestAllowExecuteDisableOnTemplate(t *testing.T) { - r := &rule{DisableOnTemplate: func(s string) *string { return &s }(`{{ ne .username "amy" }}`)} + r := &Rule{DisableOnTemplate: func(s string) *string { return &s }(`{{ ne .username "amy" }}`)} for msg, exp := range map[string]bool{ ":amy!amy@foo.example.com PRIVMSG #mychannel :Testing": true, @@ -143,7 +143,7 @@ func TestAllowExecuteDisableOnTemplate(t *testing.T) { } func TestAllowExecuteEventWhitelist(t *testing.T) { - r := &rule{MatchEvent: func(s string) *string { return &s }("test")} + r := &Rule{MatchEvent: func(s string) *string { return &s }("test")} for evt, exp := range map[string]bool{ "foobar": false, @@ -156,7 +156,7 @@ func TestAllowExecuteEventWhitelist(t *testing.T) { } func TestAllowExecuteMessageMatcherBlacklist(t *testing.T) { - r := &rule{DisableOnMatchMessages: []string{`^!disable`}} + r := &Rule{DisableOnMatchMessages: []string{`^!disable`}} for msg, exp := range map[string]bool{ "PRIVMSG #test :Random message": true, @@ -169,7 +169,7 @@ func TestAllowExecuteMessageMatcherBlacklist(t *testing.T) { } func TestAllowExecuteMessageMatcherWhitelist(t *testing.T) { - r := &rule{MatchMessage: func(s string) *string { return &s }(`^!test`)} + r := &Rule{MatchMessage: func(s string) *string { return &s }(`^!test`)} for msg, exp := range map[string]bool{ "PRIVMSG #test :Random message": false, @@ -182,7 +182,7 @@ func TestAllowExecuteMessageMatcherWhitelist(t *testing.T) { } func TestAllowExecuteRuleCooldown(t *testing.T) { - r := &rule{Cooldown: func(i time.Duration) *time.Duration { return &i }(time.Minute), SkipCooldownFor: []string{badgeBroadcaster}} + r := &Rule{Cooldown: func(i time.Duration) *time.Duration { return &i }(time.Minute), SkipCooldownFor: []string{badgeBroadcaster}} if !r.allowExecuteRuleCooldown(testLogger, nil, nil, badgeCollection{}) { t.Error("Initial call was not allowed") @@ -201,7 +201,7 @@ func TestAllowExecuteRuleCooldown(t *testing.T) { } func TestAllowExecuteUserCooldown(t *testing.T) { - r := &rule{UserCooldown: func(i time.Duration) *time.Duration { return &i }(time.Minute), SkipCooldownFor: []string{badgeBroadcaster}} + r := &Rule{UserCooldown: func(i time.Duration) *time.Duration { return &i }(time.Minute), SkipCooldownFor: []string{badgeBroadcaster}} c1 := irc.MustParseMessage(":ben!ben@foo.example.com PRIVMSG #mychannel :Testing") c2 := irc.MustParseMessage(":amy!amy@foo.example.com PRIVMSG #mychannel :Testing") @@ -226,7 +226,7 @@ func TestAllowExecuteUserCooldown(t *testing.T) { } func TestAllowExecuteUserWhitelist(t *testing.T) { - r := &rule{MatchUsers: []string{"amy"}} + r := &Rule{MatchUsers: []string{"amy"}} for msg, exp := range map[string]bool{ ":amy!amy@foo.example.com PRIVMSG #mychannel :Testing": true,