mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-20 11:51:17 +00:00
578 lines
17 KiB
Go
578 lines
17 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mitchellh/hashstructure/v2"
|
|
"github.com/pkg/errors"
|
|
"github.com/sirupsen/logrus"
|
|
"gopkg.in/irc.v4"
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/Luzifer/go_helpers/v2/fieldcollection"
|
|
"github.com/Luzifer/go_helpers/v2/str"
|
|
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
|
|
)
|
|
|
|
const (
|
|
contentTypeJSON = "json"
|
|
contentTypeYAML = "yaml"
|
|
|
|
remoteRuleFetchTimeout = 5 * time.Second
|
|
)
|
|
|
|
// ErrStopRuleExecution is a way for actions to terminate execution
|
|
// of the current rule gracefully. No actions after this has been
|
|
// returned will be executed and no error state will be set
|
|
var ErrStopRuleExecution = errors.New("stop rule execution now")
|
|
|
|
type (
|
|
// Rule represents a rule in the bot configuration
|
|
Rule struct {
|
|
UUID string `hash:"-" json:"uuid,omitempty" yaml:"uuid,omitempty"`
|
|
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
|
SubscribeFrom *string `json:"subscribe_from,omitempty" yaml:"subscribe_from,omitempty"`
|
|
|
|
Actions []*RuleAction `json:"actions,omitempty" yaml:"actions,omitempty"`
|
|
|
|
Cooldown *time.Duration `json:"cooldown,omitempty" yaml:"cooldown,omitempty"`
|
|
ChannelCooldown *time.Duration `json:"channel_cooldown,omitempty" yaml:"channel_cooldown,omitempty"`
|
|
UserCooldown *time.Duration `json:"user_cooldown,omitempty" yaml:"user_cooldown,omitempty"`
|
|
SkipCooldownFor []string `json:"skip_cooldown_for,omitempty" yaml:"skip_cooldown_for,omitempty"`
|
|
|
|
MatchChannels []string `json:"match_channels,omitempty" yaml:"match_channels,omitempty"`
|
|
MatchEvent *string `json:"match_event,omitempty" yaml:"match_event,omitempty"`
|
|
MatchMessage *string `json:"match_message,omitempty" yaml:"match_message,omitempty"`
|
|
MatchUsers []string `json:"match_users,omitempty" yaml:"match_users,omitempty" `
|
|
|
|
DisableOnMatchMessages []string `json:"disable_on_match_messages,omitempty" yaml:"disable_on_match_messages,omitempty"`
|
|
|
|
Disable *bool `json:"disable,omitempty" yaml:"disable,omitempty"`
|
|
DisableOnOffline *bool `json:"disable_on_offline,omitempty" yaml:"disable_on_offline,omitempty"`
|
|
DisableOnPermit *bool `json:"disable_on_permit,omitempty" yaml:"disable_on_permit,omitempty"`
|
|
DisableOnTemplate *string `json:"disable_on_template,omitempty" yaml:"disable_on_template,omitempty"`
|
|
DisableOn []string `json:"disable_on,omitempty" yaml:"disable_on,omitempty"`
|
|
EnableOn []string `json:"enable_on,omitempty" yaml:"enable_on,omitempty"`
|
|
|
|
//revive:disable-next-line:confusing-naming // only used internally as parsed regexp
|
|
matchMessage *regexp.Regexp
|
|
//revive:disable-next-line:confusing-naming // only used internally as parsed regexp
|
|
disableOnMatchMessages []*regexp.Regexp
|
|
|
|
msgFormatter MsgFormatter
|
|
timerStore TimerStore
|
|
twitchClient *twitch.Client
|
|
}
|
|
|
|
// RuleAction represents an action to be executed when running a Rule
|
|
RuleAction struct {
|
|
Type string `json:"type" yaml:"type,omitempty"`
|
|
Attributes *fieldcollection.FieldCollection `json:"attributes" yaml:"attributes,omitempty"`
|
|
}
|
|
)
|
|
|
|
// MatcherID returns the rule UUID or a hash for the rule if no UUID
|
|
// is available
|
|
func (r Rule) MatcherID() string {
|
|
if r.UUID != "" {
|
|
return r.UUID
|
|
}
|
|
|
|
return r.hash()
|
|
}
|
|
|
|
// Matches checks whether the Rule should be executed for the given parameters
|
|
func (r *Rule) Matches(m *irc.Message, event *string, timerStore TimerStore, msgFormatter MsgFormatter, twitchClient *twitch.Client, eventData *fieldcollection.FieldCollection) bool {
|
|
r.msgFormatter = msgFormatter
|
|
r.timerStore = timerStore
|
|
r.twitchClient = twitchClient
|
|
|
|
var (
|
|
badges = twitch.ParseBadgeLevels(m)
|
|
logger = logrus.WithFields(logrus.Fields{
|
|
"msg": m,
|
|
"rule": r,
|
|
})
|
|
)
|
|
|
|
for _, matcher := range []func(*logrus.Entry, *irc.Message, *string, twitch.BadgeCollection, *fieldcollection.FieldCollection) bool{
|
|
r.allowExecuteDisable,
|
|
r.allowExecuteChannelWhitelist,
|
|
r.allowExecuteUserWhitelist,
|
|
r.allowExecuteEventMatch,
|
|
r.allowExecuteMessageMatcherWhitelist,
|
|
r.allowExecuteMessageMatcherBlacklist,
|
|
r.allowExecuteBadgeBlacklist,
|
|
r.allowExecuteBadgeWhitelist,
|
|
r.allowExecuteDisableOnPermit,
|
|
r.allowExecuteRuleCooldown,
|
|
r.allowExecuteChannelCooldown,
|
|
r.allowExecuteUserCooldown,
|
|
r.allowExecuteDisableOnTemplate,
|
|
r.allowExecuteDisableOnOffline,
|
|
} {
|
|
if !matcher(logger, m, event, badges, eventData) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Nothing objected: Matches!
|
|
return true
|
|
}
|
|
|
|
// GetMatchMessage returns the cached Regexp if available or compiles
|
|
// the given match string into a Regexp
|
|
func (r *Rule) GetMatchMessage() *regexp.Regexp {
|
|
var err error
|
|
|
|
if r.matchMessage == nil {
|
|
if r.matchMessage, err = regexp.Compile(*r.MatchMessage); err != nil {
|
|
logrus.WithError(err).Error("Unable to compile expression")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return r.matchMessage
|
|
}
|
|
|
|
// SetCooldown uses the given TimerStore to set the cooldowns for the
|
|
// Rule after execution
|
|
func (r *Rule) SetCooldown(timerStore TimerStore, m *irc.Message, evtData *fieldcollection.FieldCollection) {
|
|
var err error
|
|
|
|
if r.Cooldown != nil {
|
|
if err = timerStore.AddCooldown(TimerTypeCooldown, "", r.MatcherID(), time.Now().Add(*r.Cooldown)); err != nil {
|
|
logrus.WithError(err).Error("setting general rule cooldown")
|
|
}
|
|
}
|
|
|
|
if r.ChannelCooldown != nil && DeriveChannel(m, evtData) != "" {
|
|
if err = timerStore.AddCooldown(TimerTypeCooldown, DeriveChannel(m, evtData), r.MatcherID(), time.Now().Add(*r.ChannelCooldown)); err != nil {
|
|
logrus.WithError(err).Error("setting channel rule cooldown")
|
|
}
|
|
}
|
|
|
|
if r.UserCooldown != nil && DeriveUser(m, evtData) != "" {
|
|
if err = timerStore.AddCooldown(TimerTypeCooldown, DeriveUser(m, evtData), r.MatcherID(), time.Now().Add(*r.UserCooldown)); err != nil {
|
|
logrus.WithError(err).Error("setting user rule cooldown")
|
|
}
|
|
}
|
|
}
|
|
|
|
// UpdateFromSubscription fetches the remote Rule source if one is
|
|
// defined and updates the rule with its content
|
|
func (r *Rule) UpdateFromSubscription(ctx context.Context) (bool, error) {
|
|
if r.SubscribeFrom == nil || len(*r.SubscribeFrom) == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
prevHash := r.hash()
|
|
|
|
remoteURL, err := url.Parse(*r.SubscribeFrom)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "parsing remote subscription url")
|
|
}
|
|
|
|
reqCtx, cancel := context.WithTimeout(ctx, remoteRuleFetchTimeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, remoteURL.String(), nil)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "assembling request")
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "executing request")
|
|
}
|
|
defer func() {
|
|
if err := resp.Body.Close(); err != nil {
|
|
logrus.WithError(err).Error("closing request body (leaked fd)")
|
|
}
|
|
}()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return false, errors.Errorf("unxpected HTTP status %d", resp.StatusCode)
|
|
}
|
|
|
|
inputType, err := r.fileTypeFromRequest(remoteURL, resp)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "detecting content type")
|
|
}
|
|
|
|
var newRule Rule
|
|
switch inputType {
|
|
case contentTypeJSON:
|
|
err = json.NewDecoder(resp.Body).Decode(&newRule)
|
|
|
|
case contentTypeYAML:
|
|
err = yaml.NewDecoder(resp.Body).Decode(&newRule)
|
|
|
|
default:
|
|
return false, errors.New("unexpected format")
|
|
}
|
|
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "decoding remote rule")
|
|
}
|
|
|
|
if newRule.hash() == prevHash {
|
|
// No update, exit now
|
|
return false, nil
|
|
}
|
|
|
|
*r = newRule
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// Validate executes some basic checks on the validity of the Rule
|
|
func (r Rule) Validate(tplValidate TemplateValidatorFunc) error {
|
|
if r.MatchMessage != nil {
|
|
if _, err := regexp.Compile(*r.MatchMessage); err != nil {
|
|
return errors.Wrap(err, "compiling match_message field regex")
|
|
}
|
|
}
|
|
|
|
if r.DisableOnTemplate != nil {
|
|
if err := tplValidate(*r.DisableOnTemplate); err != nil {
|
|
return errors.Wrap(err, "parsing disable_on_template template")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *Rule) allowExecuteBadgeBlacklist(logger *logrus.Entry, _ *irc.Message, _ *string, badges twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
|
|
for _, b := range r.DisableOn {
|
|
if badges.Has(b) {
|
|
logger.Tracef("Non-Match: Disable-Badge %s", b)
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (r *Rule) allowExecuteBadgeWhitelist(_ *logrus.Entry, _ *irc.Message, _ *string, badges twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
|
|
if len(r.EnableOn) == 0 {
|
|
// No match criteria set, does not speak against matching
|
|
return true
|
|
}
|
|
|
|
for _, b := range r.EnableOn {
|
|
if badges.Has(b) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (r *Rule) allowExecuteChannelCooldown(logger *logrus.Entry, m *irc.Message, _ *string, badges twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
|
|
if r.ChannelCooldown == nil || DeriveChannel(m, evtData) == "" {
|
|
// No match criteria set, does not speak against matching
|
|
return true
|
|
}
|
|
|
|
inCooldown, err := r.timerStore.InCooldown(TimerTypeCooldown, DeriveChannel(m, evtData), r.MatcherID())
|
|
if err != nil {
|
|
logger.WithError(err).Error("checking channel cooldown")
|
|
return false
|
|
}
|
|
|
|
if !inCooldown {
|
|
return true
|
|
}
|
|
|
|
for _, b := range r.SkipCooldownFor {
|
|
if badges.Has(b) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (r *Rule) allowExecuteChannelWhitelist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
|
|
if len(r.MatchChannels) == 0 {
|
|
// No match criteria set, does not speak against matching
|
|
return true
|
|
}
|
|
|
|
if DeriveChannel(m, evtData) == "" || (!str.StringInSlice(DeriveChannel(m, evtData), r.MatchChannels) && !str.StringInSlice(strings.TrimPrefix(DeriveChannel(m, evtData), "#"), r.MatchChannels)) {
|
|
logger.Trace("Non-Match: Channel")
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (r *Rule) allowExecuteDisable(logger *logrus.Entry, _ *irc.Message, _ *string, _ twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
|
|
if r.Disable == nil {
|
|
// No match criteria set, does not speak against matching
|
|
return true
|
|
}
|
|
|
|
if *r.Disable {
|
|
logger.Trace("Non-Match: Disable")
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (r *Rule) allowExecuteDisableOnOffline(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
|
|
if r.DisableOnOffline == nil || !*r.DisableOnOffline || DeriveChannel(m, evtData) == "" {
|
|
// No match criteria set, does not speak against matching
|
|
return true
|
|
}
|
|
|
|
streamLive, err := r.twitchClient.HasLiveStream(context.Background(), strings.TrimLeft(DeriveChannel(m, evtData), "#"))
|
|
if err != nil {
|
|
logger.WithError(err).Error("Unable to determine live status")
|
|
return false
|
|
}
|
|
if !streamLive {
|
|
logger.Trace("Non-Match: Stream offline")
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (r *Rule) allowExecuteDisableOnPermit(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
|
|
hasPermit, err := r.timerStore.HasPermit(DeriveChannel(m, evtData), DeriveUser(m, evtData))
|
|
if err != nil {
|
|
logger.WithError(err).Error("checking permit")
|
|
return false
|
|
}
|
|
|
|
if r.DisableOnPermit != nil && *r.DisableOnPermit && DeriveChannel(m, evtData) != "" && hasPermit {
|
|
logger.Trace("Non-Match: Permit")
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (r *Rule) allowExecuteDisableOnTemplate(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
|
|
if r.DisableOnTemplate == nil || *r.DisableOnTemplate == "" {
|
|
// No match criteria set, does not speak against matching
|
|
return true
|
|
}
|
|
|
|
res, err := r.msgFormatter(*r.DisableOnTemplate, m, r, evtData)
|
|
if err != nil {
|
|
logger.WithError(err).Error("Unable to check DisableOnTemplate field")
|
|
// Caused an error, forbid execution
|
|
return false
|
|
}
|
|
|
|
if res == "true" {
|
|
logger.Trace("Non-Match: Template")
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (r *Rule) allowExecuteEventMatch(logger *logrus.Entry, _ *irc.Message, event *string, _ twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
|
|
// The user defines either no event to match or they define an
|
|
// event to match. We now need to ensure this match is valid for
|
|
// the current execution:
|
|
//
|
|
// - If the user gave no event we MUST NOT have an event defined
|
|
// - If the user gave an event we MUST have the same event defined
|
|
//
|
|
// To aid fighting spam we do define some excemption from these
|
|
// rules:
|
|
//
|
|
// - Bits are sent using IRC messages and might contains spam
|
|
// therefore we additionally match them through a message
|
|
// matcher
|
|
// - Resubs are also IRC messages and might be abused to spam
|
|
// though this is quite unlikely. Even though it's unlikely
|
|
// we also allow a match for message matchers to aid mods
|
|
//
|
|
// As all set events are always pointer to non-empty strings we
|
|
// assume an empty string in case either is not set and then
|
|
// compare the string contents.
|
|
|
|
var mE, gE string
|
|
|
|
if r.MatchEvent != nil {
|
|
mE = *r.MatchEvent
|
|
}
|
|
|
|
if event != nil {
|
|
gE = *event
|
|
}
|
|
|
|
if mE == gE {
|
|
// Event does exactly match
|
|
return true
|
|
}
|
|
|
|
if mE == "" && str.StringInSlice(gE, []string{"bits", "resub"}) {
|
|
// Additional message matchers - see explanation above
|
|
return true
|
|
}
|
|
|
|
logger.Trace("Non-Match: Event")
|
|
return false
|
|
}
|
|
|
|
func (r *Rule) allowExecuteMessageMatcherBlacklist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
|
|
if len(r.DisableOnMatchMessages) == 0 {
|
|
// No match criteria set, does not speak against matching
|
|
return true
|
|
}
|
|
|
|
// If the regexps were not pre-compiled, do it now
|
|
if len(r.disableOnMatchMessages) != len(r.DisableOnMatchMessages) {
|
|
r.disableOnMatchMessages = nil
|
|
for _, dm := range r.DisableOnMatchMessages {
|
|
dmr, err := regexp.Compile(dm)
|
|
if err != nil {
|
|
logger.WithError(err).Error("Unable to compile expression")
|
|
return false
|
|
}
|
|
r.disableOnMatchMessages = append(r.disableOnMatchMessages, dmr)
|
|
}
|
|
}
|
|
|
|
for _, rex := range r.disableOnMatchMessages {
|
|
if m != nil && rex.MatchString(m.Trailing()) {
|
|
logger.Trace("Non-Match: Disable-On-Message")
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (r *Rule) allowExecuteMessageMatcherWhitelist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
|
|
if r.MatchMessage == nil {
|
|
// No match criteria set, does not speak against matching
|
|
return true
|
|
}
|
|
|
|
var err error
|
|
|
|
// If the regexp was not yet compiled, cache it
|
|
if r.matchMessage == nil {
|
|
if r.matchMessage, err = regexp.Compile(*r.MatchMessage); err != nil {
|
|
logger.WithError(err).Error("Unable to compile expression")
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check whether the message matches
|
|
if m == nil || !r.matchMessage.MatchString(m.Trailing()) {
|
|
logger.Trace("Non-Match: Message")
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (r *Rule) allowExecuteRuleCooldown(logger *logrus.Entry, _ *irc.Message, _ *string, badges twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
|
|
if r.Cooldown == nil {
|
|
// No match criteria set, does not speak against matching
|
|
return true
|
|
}
|
|
|
|
inCooldown, err := r.timerStore.InCooldown(TimerTypeCooldown, "", r.MatcherID())
|
|
if err != nil {
|
|
logger.WithError(err).Error("checking rule cooldown")
|
|
return false
|
|
}
|
|
|
|
if !inCooldown {
|
|
return true
|
|
}
|
|
|
|
for _, b := range r.SkipCooldownFor {
|
|
if badges.Has(b) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (r *Rule) allowExecuteUserCooldown(logger *logrus.Entry, m *irc.Message, _ *string, badges twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
|
|
if r.UserCooldown == nil {
|
|
// No match criteria set, does not speak against matching
|
|
return true
|
|
}
|
|
|
|
inCooldown, err := r.timerStore.InCooldown(TimerTypeCooldown, DeriveUser(m, evtData), r.MatcherID())
|
|
if err != nil {
|
|
logger.WithError(err).Error("checking user cooldown")
|
|
return false
|
|
}
|
|
|
|
if DeriveUser(m, evtData) == "" || !inCooldown {
|
|
return true
|
|
}
|
|
|
|
for _, b := range r.SkipCooldownFor {
|
|
if badges.Has(b) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (r *Rule) allowExecuteUserWhitelist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
|
|
if len(r.MatchUsers) == 0 {
|
|
// No match criteria set, does not speak against matching
|
|
return true
|
|
}
|
|
|
|
if DeriveUser(m, evtData) == "" || !str.StringInSlice(strings.ToLower(DeriveUser(m, evtData)), r.MatchUsers) {
|
|
logger.Trace("Non-Match: Users")
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (Rule) fileTypeFromRequest(remoteURL *url.URL, resp *http.Response) (string, error) {
|
|
switch path.Ext(remoteURL.Path) {
|
|
case ".json":
|
|
return contentTypeJSON, nil
|
|
|
|
case ".yaml", ".yml":
|
|
return contentTypeYAML, nil
|
|
}
|
|
|
|
switch strings.Split(resp.Header.Get("Content-Type"), ";")[0] {
|
|
case "application/json":
|
|
return contentTypeJSON, nil
|
|
|
|
case "application/yaml", "application/x-yaml", "text/x-yaml":
|
|
return contentTypeYAML, nil
|
|
}
|
|
|
|
return "", errors.New("no valid file type detected")
|
|
}
|
|
|
|
func (r Rule) hash() string {
|
|
h, err := hashstructure.Hash(r, hashstructure.FormatV2, nil)
|
|
if err != nil {
|
|
panic(errors.Wrap(err, "hashing rule"))
|
|
}
|
|
return fmt.Sprintf("hashstructure:%x", h)
|
|
}
|