package linkprotect import ( "regexp" "strings" "time" "github.com/go-irc/irc" "github.com/pkg/errors" "github.com/Luzifer/twitch-bot/v3/internal/actors/clipdetector" "github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/plugins" ) const actorName = "linkprotect" var ( botTwitchClient *twitch.Client clipLink = regexp.MustCompile(`.*(?:clips\.twitch\.tv|www\.twitch\.tv/[^/]*/clip)/.*`) ptrBoolFalse = func(v bool) *bool { return &v }(false) ptrStringEmpty = func(v string) *string { return &v }("") ) func Register(args plugins.RegistrationArguments) error { botTwitchClient = args.GetTwitchClient() args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) args.RegisterActorDocumentation(plugins.ActionDocumentation{ Description: `Uses link- and clip-scanner to detect links / clips and applies link protection as defined`, Name: "Enforce Link-Protection", Type: actorName, Fields: []plugins.ActionDocumentationField{ { Default: "", Description: "Allowed links (if any is specified all non matching links will cause enforcement action, link must contain any of these strings)", Key: "allowed_links", Name: "Allowed Links", Optional: true, SupportTemplate: false, Type: plugins.ActionDocumentationFieldTypeStringSlice, }, { Default: "", Description: "Disallowed links (if any is specified all non matching links will not cause enforcement action, link must contain any of these strings)", Key: "disallowed_links", Name: "Disallowed Links", Optional: true, SupportTemplate: false, Type: plugins.ActionDocumentationFieldTypeStringSlice, }, { Default: "", Description: "Allowed clip channels (if any is specified clips of all other channels will cause enforcement action, clip-links will be ignored in link-protection when this is used)", Key: "allowed_clip_channels", Name: "Allowed Clip Channels", Optional: true, SupportTemplate: false, Type: plugins.ActionDocumentationFieldTypeStringSlice, }, { Default: "", Description: "Disallowed clip channels (if any is specified clips of all other channels will not cause enforcement action, clip-links will be ignored in link-protection when this is used)", Key: "disallowed_clip_channels", Name: "Disallowed Clip Channels", Optional: true, SupportTemplate: false, Type: plugins.ActionDocumentationFieldTypeStringSlice, }, { Default: "", Description: "Enforcement action to take when disallowed link / clip is detected (ban, delete, duration-value i.e. 1m)", Key: "action", Name: "Action", Optional: false, SupportTemplate: false, Type: plugins.ActionDocumentationFieldTypeString, }, { Default: "", Description: "Reason why the enforcement action was taken", Key: "reason", Name: "Reason", Optional: false, SupportTemplate: false, Type: plugins.ActionDocumentationFieldTypeString, }, { Default: "false", Description: "Stop rule execution when action is applied (i.e. not to post a message after a ban for spam links)", Key: "stop_on_action", Name: "Stop on Action", Optional: true, SupportTemplate: false, Type: plugins.ActionDocumentationFieldTypeBool, }, { Default: "false", Description: "Stop rule execution when no action is applied (i.e. not to post a message when no enforcement action is taken)", Key: "stop_on_no_action", Name: "Stop on no Action", Optional: true, SupportTemplate: false, Type: plugins.ActionDocumentationFieldTypeBool, }, }, }) return nil } type ( actor struct{} verdict uint ) const ( verdictAllFine verdict = iota verdictMisbehave ) //nolint:gocyclo // Minimum over the limit, makes no sense to split func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { // In case the clip detector did not run before, lets run it now if preventCooldown, err = (clipdetector.Actor{}).Execute(c, m, r, eventData, attrs); err != nil { return preventCooldown, errors.Wrap(err, "detecting links / clips") } links, err := eventData.StringSlice("links") if err != nil { return preventCooldown, errors.Wrap(err, "getting links from event") } if len(links) == 0 { // If there are no links there is nothing to protect and there // are also no clips as they are parsed from the links if attrs.MustBool("stop_on_no_action", ptrBoolFalse) { return false, plugins.ErrStopRuleExecution } return false, nil } clipsInterface, err := eventData.Any("clips") if err != nil { return preventCooldown, errors.Wrap(err, "getting clips from event") } clips, ok := clipsInterface.([]twitch.ClipInfo) if !ok { return preventCooldown, errors.New("invalid data-type in clips") } if a.check(links, clips, attrs) == verdictAllFine { if attrs.MustBool("stop_on_no_action", ptrBoolFalse) { return false, plugins.ErrStopRuleExecution } return false, nil } // That message misbehaved so we need to punish them switch lt := attrs.MustString("action", ptrStringEmpty); lt { case "ban": if err = botTwitchClient.BanUser( plugins.DeriveChannel(m, eventData), strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"), 0, attrs.MustString("reason", ptrStringEmpty), ); err != nil { return false, errors.Wrap(err, "executing user ban") } case "delete": msgID, ok := m.Tags.GetTag("id") if !ok || msgID == "" { return false, errors.New("found no mesage id") } if err = botTwitchClient.DeleteMessage( plugins.DeriveChannel(m, eventData), msgID, ); err != nil { return false, errors.Wrap(err, "deleting message") } default: to, err := time.ParseDuration(lt) if err != nil { return false, errors.Wrap(err, "parsing punishment level") } if err = botTwitchClient.BanUser( plugins.DeriveChannel(m, eventData), strings.TrimLeft(plugins.DeriveUser(m, eventData), "@"), to, attrs.MustString("reason", ptrStringEmpty), ); err != nil { return false, errors.Wrap(err, "executing user ban") } } if attrs.MustBool("stop_on_action", ptrBoolFalse) { return false, plugins.ErrStopRuleExecution } return false, nil } func (actor) IsAsync() bool { return false } func (actor) Name() string { return actorName } func (actor) Validate(_ plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) error { if v, err := attrs.String("action"); err != nil || v == "" { return errors.New("action must be non-empty string") } if v, err := attrs.String("reason"); err != nil || v == "" { return errors.New("reason must be non-empty string") } if len(attrs.MustStringSlice("allowed_links"))+ len(attrs.MustStringSlice("disallowed_links"))+ len(attrs.MustStringSlice("allowed_clip_channels"))+ len(attrs.MustStringSlice("disallowed_clip_channels")) == 0 { return errors.New("no conditions are provided") } return nil } func (a actor) check(links []string, clips []twitch.ClipInfo, attrs *plugins.FieldCollection) (v verdict) { hasClipDefinition := len(attrs.MustStringSlice("allowed_clip_channels"))+len(attrs.MustStringSlice("disallowed_clip_channels")) > 0 if v = a.checkLinkDenied(attrs.MustStringSlice("disallowed_links"), links, hasClipDefinition); v == verdictMisbehave { return verdictMisbehave } if v = a.checkAllLinksAllowed(attrs.MustStringSlice("allowed_links"), links, hasClipDefinition); v == verdictMisbehave { return verdictMisbehave } if v = a.checkClipChannelDenied(attrs.MustStringSlice("disallowed_clip_channels"), clips); v == verdictMisbehave { return verdictMisbehave } if v = a.checkAllClipChannelsAllowed(attrs.MustStringSlice("allowed_clip_channels"), clips); v == verdictMisbehave { return verdictMisbehave } return verdictAllFine } func (actor) checkAllClipChannelsAllowed(allowList []string, clips []twitch.ClipInfo) verdict { if len(allowList) == 0 { // We're not explicitly allowing clip-channels, this method is a no-op return verdictAllFine } allAllowed := true for _, clip := range clips { clipAllowed := false for _, allowed := range allowList { if strings.EqualFold(clip.BroadcasterName, allowed) { clipAllowed = true } } allAllowed = allAllowed && clipAllowed } if allAllowed { // All clips are fine return verdictAllFine } // Some clips are not fine return verdictMisbehave } func (actor) checkClipChannelDenied(denyList []string, clips []twitch.ClipInfo) verdict { for _, clip := range clips { for _, denied := range denyList { if strings.EqualFold(clip.BroadcasterName, denied) { return verdictMisbehave } } } return verdictAllFine } func (actor) checkAllLinksAllowed(allowList, links []string, autoAllowClipLinks bool) verdict { if len(allowList) == 0 { // We're not explicitly allowing links, this method is a no-op return verdictAllFine } allAllowed := true for _, link := range links { if autoAllowClipLinks && clipLink.MatchString(link) { // The default is "true", so we don't change that in this case // as the expression would be `allowList && true` which is BS continue } var linkAllowed bool for _, allowed := range allowList { linkAllowed = linkAllowed || strings.Contains(strings.ToLower(link), strings.ToLower(allowed)) } allAllowed = allAllowed && linkAllowed } if allAllowed { // All links are fine return verdictAllFine } // Some links are not fine return verdictMisbehave } func (actor) checkLinkDenied(denyList, links []string, ignoreClipLinks bool) verdict { for _, link := range links { if ignoreClipLinks && clipLink.MatchString(link) { // We have special directives for clips so we ignore clip-links continue } for _, denied := range denyList { if strings.Contains(strings.ToLower(link), strings.ToLower(denied)) { // Well, that link is definitely not allowed return verdictMisbehave } } } return verdictAllFine }