Use more flexible Actor format to allow addition of new actors (#5)

This commit is contained in:
Knut Ahlers 2021-06-11 13:52:42 +02:00 committed by GitHub
parent 0db778f841
commit ede8a95ed4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 453 additions and 327 deletions

View File

@ -8,20 +8,29 @@ import (
) )
func init() { func init() {
registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { registerAction(func() Actor { return &ActorBan{} })
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",
)
})
} }
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" }

View File

@ -8,41 +8,52 @@ import (
) )
func init() { func init() {
registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { registerAction(func() Actor { return &ActorCounter{} })
if r.Counter == nil { }
return nil
}
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 { if err != nil {
return errors.Wrap(err, "preparing response") return errors.Wrap(err, "execute counter value template")
} }
if r.CounterSet != nil { counterValue, err := strconv.ParseInt(parseValue, 10, 64)
parseValue, err := formatMessage(*r.CounterSet, m, ruleDef, nil) if err != nil {
if err != nil { return errors.Wrap(err, "parse counter value")
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
} }
return errors.Wrap( return errors.Wrap(
store.UpdateCounter(counterName, counterStep, false), store.UpdateCounter(counterName, counterValue, true),
"update counter", "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" }

View File

@ -8,17 +8,27 @@ import (
) )
func init() { func init() {
registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { registerAction(func() Actor { return &ActorDelay{} })
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
})
} }
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" }

View File

@ -8,25 +8,34 @@ import (
) )
func init() { func init() {
registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { registerAction(func() Actor { return &ActorDelete{} })
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",
)
})
} }
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" }

View File

@ -6,24 +6,33 @@ import (
) )
func init() { func init() {
registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { registerAction(func() Actor { return &ActorRaw{} })
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",
)
})
} }
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" }

View File

@ -6,42 +6,53 @@ import (
) )
func init() { func init() {
registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { registerAction(func() Actor { return &ActorRespond{} })
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",
)
})
} }
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" }

View File

@ -12,70 +12,79 @@ import (
) )
func init() { func init() {
registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { registerAction(func() Actor { return &ActorScript{} })
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
})
} }
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" }

View File

@ -9,20 +9,29 @@ import (
) )
func init() { func init() {
registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { registerAction(func() Actor { return &ActorTimeout{} })
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",
)
})
} }
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" }

View File

@ -8,35 +8,45 @@ import (
) )
func init() { func init() {
registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { registerAction(func() Actor { return &ActorWhisper{} })
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",
)
})
} }
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" }

View File

@ -8,26 +8,58 @@ import (
log "github.com/sirupsen/logrus" 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 ( var (
availableActions []actionFunc availableActions []ActorCreationFunc
availableActionsLock = new(sync.RWMutex) availableActionsLock = new(sync.RWMutex)
) )
type actionFunc func(*irc.Client, *irc.Message, *rule, *ruleAction) error func registerAction(af ActorCreationFunc) {
func registerAction(af actionFunc) {
availableActionsLock.Lock() availableActionsLock.Lock()
defer availableActionsLock.Unlock() defer availableActionsLock.Unlock()
availableActions = append(availableActions, af) 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() availableActionsLock.RLock()
defer availableActionsLock.RUnlock() defer availableActionsLock.RUnlock()
for _, af := range availableActions { for _, acf := range availableActions {
if err := af(c, m, rule, ra); err != nil { 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") return errors.Wrap(err, "execute action")
} }
} }
@ -44,6 +76,6 @@ func handleMessage(c *irc.Client, m *irc.Message, event *string) {
} }
// Lock command // Lock command
r.SetCooldown(m) r.setCooldown(m)
} }
} }

View File

@ -19,7 +19,7 @@ type configFile struct {
PermitAllowModerator bool `yaml:"permit_allow_moderator"` PermitAllowModerator bool `yaml:"permit_allow_moderator"`
PermitTimeout time.Duration `yaml:"permit_timeout"` PermitTimeout time.Duration `yaml:"permit_timeout"`
RawLog string `yaml:"raw_log"` RawLog string `yaml:"raw_log"`
Rules []*rule `yaml:"rules"` Rules []*Rule `yaml:"rules"`
Variables map[string]interface{} `yaml:"variables"` Variables map[string]interface{} `yaml:"variables"`
rawLogWriter io.WriteCloser rawLogWriter io.WriteCloser
@ -125,14 +125,14 @@ func (c *configFile) CloseRawMessageWriter() error {
return c.rawLogWriter.Close() 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() configLock.RLock()
defer configLock.RUnlock() defer configLock.RUnlock()
var out []*rule var out []*Rule
for _, r := range c.Rules { for _, r := range c.Rules {
if r.Matches(m, event) { if r.matches(m, event) {
out = append(out, r) out = append(out, r)
} }
} }

View File

@ -12,7 +12,7 @@ import (
var tplFuncs = newTemplateFuncProvider() var tplFuncs = newTemplateFuncProvider()
type ( type (
templateFuncGetter func(*irc.Message, *rule, map[string]interface{}) interface{} templateFuncGetter func(*irc.Message, *Rule, map[string]interface{}) interface{}
templateFuncProvider struct { templateFuncProvider struct {
funcs map[string]templateFuncGetter funcs map[string]templateFuncGetter
lock *sync.RWMutex lock *sync.RWMutex
@ -28,7 +28,7 @@ func newTemplateFuncProvider() *templateFuncProvider {
return out 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() t.lock.RLock()
defer t.lock.RUnlock() defer t.lock.RUnlock()
@ -49,7 +49,7 @@ func (t *templateFuncProvider) Register(name string, fg templateFuncGetter) {
} }
func genericTemplateFunctionGetter(f interface{}) 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() { func init() {

View File

@ -8,7 +8,7 @@ import (
) )
func init() { 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) { return func(name string) (string, error) {
channel, ok := fields["channel"].(string) channel, ok := fields["channel"].(string)
if !ok { if !ok {

View File

@ -8,7 +8,7 @@ import (
) )
func init() { 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) { return func(arg int) (string, error) {
msgParts := strings.Split(m.Trailing(), " ") msgParts := strings.Split(m.Trailing(), " ")
if len(msgParts) <= arg { if len(msgParts) <= arg {
@ -21,7 +21,7 @@ func init() {
tplFuncs.Register("fixUsername", genericTemplateFunctionGetter(func(username string) string { return strings.TrimLeft(username, "@#") })) 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) { return func(idx int) (string, error) {
fields := r.matchMessage.FindStringSubmatch(m.Trailing()) fields := r.matchMessage.FindStringSubmatch(m.Trailing())
if len(fields) <= idx { 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 { return func(tag string) string {
s, _ := m.GetTag(tag) s, _ := m.GetTag(tag)
return s return s

View File

@ -9,7 +9,7 @@ import (
"github.com/pkg/errors" "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{}{} compiledFields := map[string]interface{}{}
if config != nil { if config != nil {

93
rule.go
View File

@ -1,7 +1,9 @@
package main package main
import ( import (
"bytes"
"crypto/sha256" "crypto/sha256"
"encoding/json"
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
@ -9,11 +11,12 @@ import (
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
"github.com/go-irc/irc" "github.com/go-irc/irc"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
type rule struct { type Rule struct {
Actions []*ruleAction `yaml:"actions"` Actions []*RuleAction `yaml:"actions"`
Cooldown *time.Duration `yaml:"cooldown"` Cooldown *time.Duration `yaml:"cooldown"`
ChannelCooldown *time.Duration `yaml:"channel_cooldown"` ChannelCooldown *time.Duration `yaml:"channel_cooldown"`
@ -38,7 +41,7 @@ type rule struct {
disableOnMatchMessages []*regexp.Regexp disableOnMatchMessages []*regexp.Regexp
} }
func (r rule) MatcherID() string { func (r Rule) MatcherID() string {
out := sha256.New() out := sha256.New()
for _, e := range []*string{ for _, e := range []*string{
@ -54,7 +57,7 @@ func (r rule) MatcherID() string {
return fmt.Sprintf("sha256:%x", out.Sum(nil)) 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 ( var (
badges = ircHandler{}.ParseBadgeLevels(m) badges = ircHandler{}.ParseBadgeLevels(m)
logger = log.WithFields(log.Fields{ logger = log.WithFields(log.Fields{
@ -88,7 +91,7 @@ func (r *rule) Matches(m *irc.Message, event *string) bool {
return true return true
} }
func (r *rule) SetCooldown(m *irc.Message) { func (r *Rule) setCooldown(m *irc.Message) {
if r.Cooldown != nil { if r.Cooldown != nil {
timerStore.AddCooldown(timerTypeCooldown, "", r.MatcherID()) 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 { for _, b := range r.DisableOn {
if badges.Has(b) { if badges.Has(b) {
logger.Tracef("Non-Match: Disable-Badge %s", 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 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 { if len(r.EnableOn) == 0 {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -128,7 +131,7 @@ func (r *rule) allowExecuteBadgeWhitelist(logger *log.Entry, m *irc.Message, eve
return false 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 { if r.ChannelCooldown == nil || len(m.Params) < 1 {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -147,7 +150,7 @@ func (r *rule) allowExecuteChannelCooldown(logger *log.Entry, m *irc.Message, ev
return false 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 { if len(r.MatchChannels) == 0 {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -161,7 +164,7 @@ func (r *rule) allowExecuteChannelWhitelist(logger *log.Entry, m *irc.Message, e
return true 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 { if r.Disable == nil {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -175,7 +178,7 @@ func (r *rule) allowExecuteDisable(logger *log.Entry, m *irc.Message, event *str
return true 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 { if r.DisableOnOffline == nil || !*r.DisableOnOffline {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -194,7 +197,7 @@ func (r *rule) allowExecuteDisableOnOffline(logger *log.Entry, m *irc.Message, e
return true 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) { if r.DisableOnPermit != nil && *r.DisableOnPermit && timerStore.HasPermit(m.Params[0], m.User) {
logger.Trace("Non-Match: Permit") logger.Trace("Non-Match: Permit")
return false return false
@ -203,7 +206,7 @@ func (r *rule) allowExecuteDisableOnPermit(logger *log.Entry, m *irc.Message, ev
return true 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 { if r.DisableOnTemplate == nil {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -224,7 +227,7 @@ func (r *rule) allowExecuteDisableOnTemplate(logger *log.Entry, m *irc.Message,
return true 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 { if r.MatchEvent == nil {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -238,7 +241,7 @@ func (r *rule) allowExecuteEventWhitelist(logger *log.Entry, m *irc.Message, eve
return true 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 { if len(r.DisableOnMatchMessages) == 0 {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -267,7 +270,7 @@ func (r *rule) allowExecuteMessageMatcherBlacklist(logger *log.Entry, m *irc.Mes
return true 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 { if r.MatchMessage == nil {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -292,7 +295,7 @@ func (r *rule) allowExecuteMessageMatcherWhitelist(logger *log.Entry, m *irc.Mes
return true 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 { if r.Cooldown == nil {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -311,7 +314,7 @@ func (r *rule) allowExecuteRuleCooldown(logger *log.Entry, m *irc.Message, event
return false 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 { if r.UserCooldown == nil {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -330,7 +333,7 @@ func (r *rule) allowExecuteUserCooldown(logger *log.Entry, m *irc.Message, event
return false 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 { if len(r.MatchUsers) == 0 {
// No match criteria set, does not speak against matching // No match criteria set, does not speak against matching
return true return true
@ -344,28 +347,32 @@ func (r *rule) allowExecuteUserWhitelist(logger *log.Entry, m *irc.Message, even
return true return true
} }
type ruleAction struct { type RuleAction struct {
Ban *string `json:"ban" yaml:"ban"` yamlUnmarshal func(interface{}) error
jsonValue []byte
Command []string `json:"command" yaml:"command"` }
CounterSet *string `json:"counter_set" yaml:"counter_set"` func (r *RuleAction) UnmarshalJSON(d []byte) error {
CounterStep *int64 `json:"counter_step" yaml:"counter_step"` r.jsonValue = d
Counter *string `json:"counter" yaml:"counter"` return nil
}
Delay time.Duration `json:"delay" yaml:"delay"`
DelayJitter time.Duration `json:"delay_jitter" yaml:"delay_jitter"` func (r *RuleAction) UnmarshalYAML(unmarshal func(interface{}) error) error {
r.yamlUnmarshal = unmarshal
DeleteMessage *bool `json:"delete_message" yaml:"delete_message"` return nil
}
RawMessage *string `json:"raw_message" yaml:"raw_message"`
func (r *RuleAction) Unmarshal(v interface{}) error {
Respond *string `json:"respond" yaml:"respond"` switch {
RespondAsReply *bool `json:"respond_as_reply" yaml:"respond_as_reply"` case r.yamlUnmarshal != nil:
RespondFallback *string `json:"respond_fallback" yaml:"respond_fallback"` return r.yamlUnmarshal(v)
Timeout *time.Duration `json:"timeout" yaml:"timeout"` case r.jsonValue != nil:
jd := json.NewDecoder(bytes.NewReader(r.jsonValue))
WhisperMessage *string `json:"whisper_message" yaml:"whisper_message"` jd.DisallowUnknownFields()
WhisperTo *string `json:"whisper_to" yaml:"whisper_to"` return jd.Decode(v)
default:
return errors.New("unmarshal on unprimed object")
}
} }

View File

@ -16,7 +16,7 @@ var (
) )
func TestAllowExecuteBadgeBlacklist(t *testing.T) { func TestAllowExecuteBadgeBlacklist(t *testing.T) {
r := &rule{DisableOn: []string{badgeBroadcaster}} r := &Rule{DisableOn: []string{badgeBroadcaster}}
if r.allowExecuteBadgeBlacklist(testLogger, nil, nil, badgeCollection{badgeBroadcaster: testBadgeLevel0}) { if r.allowExecuteBadgeBlacklist(testLogger, nil, nil, badgeCollection{badgeBroadcaster: testBadgeLevel0}) {
t.Error("Execution allowed on blacklisted badge") t.Error("Execution allowed on blacklisted badge")
@ -28,7 +28,7 @@ func TestAllowExecuteBadgeBlacklist(t *testing.T) {
} }
func TestAllowExecuteBadgeWhitelist(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}) { if r.allowExecuteBadgeWhitelist(testLogger, nil, nil, badgeCollection{badgeModerator: testBadgeLevel0}) {
t.Error("Execution allowed without whitelisted badge") t.Error("Execution allowed without whitelisted badge")
@ -40,7 +40,7 @@ func TestAllowExecuteBadgeWhitelist(t *testing.T) {
} }
func TestAllowExecuteChannelWhitelist(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{ for m, exp := range map[string]bool{
":amy!amy@foo.example.com PRIVMSG #mychannel :Testing": true, ":amy!amy@foo.example.com PRIVMSG #mychannel :Testing": true,
@ -59,7 +59,7 @@ func TestAllowExecuteChannelWhitelist(t *testing.T) {
} }
func TestAllowExecuteDisable(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)}, true: {Disable: testPtrBool(false)},
false: {Disable: testPtrBool(true)}, false: {Disable: testPtrBool(true)},
} { } {
@ -70,7 +70,7 @@ func TestAllowExecuteDisable(t *testing.T) {
} }
func TestAllowExecuteDisableOnOffline(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 // Fake cache entries to prevent calling the real Twitch API
twitch.apiCache.Set([]string{"hasLiveStream", "channel1"}, time.Minute, true) twitch.apiCache.Set([]string{"hasLiveStream", "channel1"}, time.Minute, true)
@ -87,7 +87,7 @@ func TestAllowExecuteDisableOnOffline(t *testing.T) {
} }
func TestAllowExecuteChannelCooldown(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") c1 := irc.MustParseMessage(":amy!amy@foo.example.com PRIVMSG #mychannel :Testing")
c2 := irc.MustParseMessage(":amy!amy@foo.example.com PRIVMSG #otherchannel :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) { 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 // Permit is using global configuration, so we must fake that one
config = &configFile{PermitTimeout: time.Minute} config = &configFile{PermitTimeout: time.Minute}
@ -130,7 +130,7 @@ func TestAllowExecuteDisableOnPermit(t *testing.T) {
} }
func TestAllowExecuteDisableOnTemplate(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{ for msg, exp := range map[string]bool{
":amy!amy@foo.example.com PRIVMSG #mychannel :Testing": true, ":amy!amy@foo.example.com PRIVMSG #mychannel :Testing": true,
@ -143,7 +143,7 @@ func TestAllowExecuteDisableOnTemplate(t *testing.T) {
} }
func TestAllowExecuteEventWhitelist(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{ for evt, exp := range map[string]bool{
"foobar": false, "foobar": false,
@ -156,7 +156,7 @@ func TestAllowExecuteEventWhitelist(t *testing.T) {
} }
func TestAllowExecuteMessageMatcherBlacklist(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{ for msg, exp := range map[string]bool{
"PRIVMSG #test :Random message": true, "PRIVMSG #test :Random message": true,
@ -169,7 +169,7 @@ func TestAllowExecuteMessageMatcherBlacklist(t *testing.T) {
} }
func TestAllowExecuteMessageMatcherWhitelist(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{ for msg, exp := range map[string]bool{
"PRIVMSG #test :Random message": false, "PRIVMSG #test :Random message": false,
@ -182,7 +182,7 @@ func TestAllowExecuteMessageMatcherWhitelist(t *testing.T) {
} }
func TestAllowExecuteRuleCooldown(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{}) { if !r.allowExecuteRuleCooldown(testLogger, nil, nil, badgeCollection{}) {
t.Error("Initial call was not allowed") t.Error("Initial call was not allowed")
@ -201,7 +201,7 @@ func TestAllowExecuteRuleCooldown(t *testing.T) {
} }
func TestAllowExecuteUserCooldown(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") c1 := irc.MustParseMessage(":ben!ben@foo.example.com PRIVMSG #mychannel :Testing")
c2 := irc.MustParseMessage(":amy!amy@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) { func TestAllowExecuteUserWhitelist(t *testing.T) {
r := &rule{MatchUsers: []string{"amy"}} r := &Rule{MatchUsers: []string{"amy"}}
for msg, exp := range map[string]bool{ for msg, exp := range map[string]bool{
":amy!amy@foo.example.com PRIVMSG #mychannel :Testing": true, ":amy!amy@foo.example.com PRIVMSG #mychannel :Testing": true,