2024-01-01 16:52:18 +00:00
// Package linkprotect contains an actor to prevent chatters from
// posting certain links
2023-04-07 22:41:00 +00:00
package linkprotect
import (
2024-01-01 16:52:18 +00:00
"context"
2024-04-03 19:00:28 +00:00
"fmt"
2023-04-07 22:41:00 +00:00
"regexp"
"strings"
"time"
"github.com/pkg/errors"
2023-09-11 17:51:38 +00:00
"gopkg.in/irc.v4"
2023-04-07 22:41:00 +00:00
2024-04-03 19:00:28 +00:00
"github.com/Luzifer/go_helpers/v2/fieldcollection"
2023-04-07 22:41:00 +00:00
"github.com/Luzifer/twitch-bot/v3/internal/actors/clipdetector"
2024-04-03 19:00:28 +00:00
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
2023-04-07 22:41:00 +00:00
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
const actorName = "linkprotect"
var (
2024-04-05 10:19:22 +00:00
botTwitchClient func ( ) * twitch . Client
2023-04-07 22:41:00 +00:00
clipLink = regexp . MustCompile ( ` .*(?:clips\.twitch\.tv|www\.twitch\.tv/[^/]*/clip)/.* ` )
)
2024-01-01 16:52:18 +00:00
// Register provides the plugins.RegisterFunc
2023-04-07 22:41:00 +00:00
func Register ( args plugins . RegistrationArguments ) error {
2024-04-05 10:19:22 +00:00
botTwitchClient = args . GetTwitchClient
2023-04-07 22:41:00 +00:00
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
2024-04-03 19:00:28 +00:00
func ( a actor ) Execute ( c * irc . Client , m * irc . Message , r * plugins . Rule , eventData * fieldcollection . FieldCollection , attrs * fieldcollection . FieldCollection ) ( preventCooldown bool , err error ) {
2023-04-07 22:41:00 +00:00
// 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
2024-04-03 19:00:28 +00:00
if attrs . MustBool ( "stop_on_no_action" , helpers . Ptr ( false ) ) {
2023-04-13 21:48:12 +00:00
return false , plugins . ErrStopRuleExecution
}
2023-04-07 22:41:00 +00:00
return false , nil
}
2024-04-03 19:00:28 +00:00
clipsInterface , err := eventData . Get ( "clips" )
2023-04-07 22:41:00 +00:00
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 {
2024-04-03 19:00:28 +00:00
if attrs . MustBool ( "stop_on_no_action" , helpers . Ptr ( false ) ) {
2023-04-07 22:41:00 +00:00
return false , plugins . ErrStopRuleExecution
}
return false , nil
}
// That message misbehaved so we need to punish them
2024-04-03 19:00:28 +00:00
switch lt := attrs . MustString ( "action" , helpers . Ptr ( "" ) ) ; lt {
2023-04-07 22:41:00 +00:00
case "ban" :
2024-04-05 10:19:22 +00:00
if err = botTwitchClient ( ) . BanUser (
2024-01-01 16:52:18 +00:00
context . Background ( ) ,
2023-04-07 22:41:00 +00:00
plugins . DeriveChannel ( m , eventData ) ,
strings . TrimLeft ( plugins . DeriveUser ( m , eventData ) , "@" ) ,
0 ,
2024-04-03 19:00:28 +00:00
attrs . MustString ( "reason" , helpers . Ptr ( "" ) ) ,
2023-04-07 22:41:00 +00:00
) ; err != nil {
return false , errors . Wrap ( err , "executing user ban" )
}
case "delete" :
2023-09-11 17:51:38 +00:00
msgID , ok := m . Tags [ "id" ]
2023-04-07 22:41:00 +00:00
if ! ok || msgID == "" {
return false , errors . New ( "found no mesage id" )
}
2024-04-05 10:19:22 +00:00
if err = botTwitchClient ( ) . DeleteMessage (
2024-01-01 16:52:18 +00:00
context . Background ( ) ,
2023-04-07 22:41:00 +00:00
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" )
}
2024-04-05 10:19:22 +00:00
if err = botTwitchClient ( ) . BanUser (
2024-01-01 16:52:18 +00:00
context . Background ( ) ,
2023-04-07 22:41:00 +00:00
plugins . DeriveChannel ( m , eventData ) ,
strings . TrimLeft ( plugins . DeriveUser ( m , eventData ) , "@" ) ,
to ,
2024-04-03 19:00:28 +00:00
attrs . MustString ( "reason" , helpers . Ptr ( "" ) ) ,
2023-04-07 22:41:00 +00:00
) ; err != nil {
return false , errors . Wrap ( err , "executing user ban" )
}
}
2024-04-03 19:00:28 +00:00
if attrs . MustBool ( "stop_on_action" , helpers . Ptr ( false ) ) {
2023-04-07 22:41:00 +00:00
return false , plugins . ErrStopRuleExecution
}
return false , nil
}
func ( actor ) IsAsync ( ) bool { return false }
func ( actor ) Name ( ) string { return actorName }
2024-04-03 19:00:28 +00:00
func ( actor ) Validate ( _ plugins . TemplateValidatorFunc , attrs * fieldcollection . FieldCollection ) ( err error ) {
if err = attrs . ValidateSchema (
fieldcollection . MustHaveField ( fieldcollection . SchemaField { Name : "action" , NonEmpty : true , Type : fieldcollection . SchemaFieldTypeString } ) ,
fieldcollection . MustHaveField ( fieldcollection . SchemaField { Name : "reason" , NonEmpty : true , Type : fieldcollection . SchemaFieldTypeString } ) ,
2024-04-08 13:56:12 +00:00
fieldcollection . CanHaveField ( fieldcollection . SchemaField { Name : "allowed_links" , Type : fieldcollection . SchemaFieldTypeStringSlice } ) ,
fieldcollection . CanHaveField ( fieldcollection . SchemaField { Name : "disallowed_links" , Type : fieldcollection . SchemaFieldTypeStringSlice } ) ,
fieldcollection . CanHaveField ( fieldcollection . SchemaField { Name : "allowed_clip_channels" , Type : fieldcollection . SchemaFieldTypeStringSlice } ) ,
fieldcollection . CanHaveField ( fieldcollection . SchemaField { Name : "disallowed_clip_channels" , Type : fieldcollection . SchemaFieldTypeStringSlice } ) ,
fieldcollection . CanHaveField ( fieldcollection . SchemaField { Name : "stop_on_action" , Type : fieldcollection . SchemaFieldTypeBool } ) ,
fieldcollection . CanHaveField ( fieldcollection . SchemaField { Name : "stop_on_no_action" , Type : fieldcollection . SchemaFieldTypeBool } ) ,
fieldcollection . MustHaveNoUnknowFields ,
2024-04-03 19:00:28 +00:00
func ( attrs , _ * fieldcollection . FieldCollection ) error {
if len ( attrs . MustStringSlice ( "allowed_links" , helpers . Ptr ( [ ] string { } ) ) ) +
len ( attrs . MustStringSlice ( "disallowed_links" , helpers . Ptr ( [ ] string { } ) ) ) +
len ( attrs . MustStringSlice ( "allowed_clip_channels" , helpers . Ptr ( [ ] string { } ) ) ) +
len ( attrs . MustStringSlice ( "disallowed_clip_channels" , helpers . Ptr ( [ ] string { } ) ) ) == 0 {
return errors . New ( "no conditions are provided" )
}
return nil
} ,
) ; err != nil {
return fmt . Errorf ( "validating attributes: %w" , err )
2023-04-07 22:41:00 +00:00
}
return nil
}
2024-04-03 19:00:28 +00:00
func ( a actor ) check ( links [ ] string , clips [ ] twitch . ClipInfo , attrs * fieldcollection . FieldCollection ) ( v verdict ) {
hasClipDefinition := len ( attrs . MustStringSlice ( "allowed_clip_channels" , helpers . Ptr ( [ ] string { } ) ) ) + len ( attrs . MustStringSlice ( "disallowed_clip_channels" , helpers . Ptr ( [ ] string { } ) ) ) > 0
2023-04-07 22:41:00 +00:00
2024-04-03 19:00:28 +00:00
if v = a . checkLinkDenied ( attrs . MustStringSlice ( "disallowed_links" , helpers . Ptr ( [ ] string { } ) ) , links , hasClipDefinition ) ; v == verdictMisbehave {
2023-04-07 22:41:00 +00:00
return verdictMisbehave
}
2024-04-03 19:00:28 +00:00
if v = a . checkAllLinksAllowed ( attrs . MustStringSlice ( "allowed_links" , helpers . Ptr ( [ ] string { } ) ) , links , hasClipDefinition ) ; v == verdictMisbehave {
2023-04-07 22:41:00 +00:00
return verdictMisbehave
}
2024-04-03 19:00:28 +00:00
if v = a . checkClipChannelDenied ( attrs . MustStringSlice ( "disallowed_clip_channels" , helpers . Ptr ( [ ] string { } ) ) , clips ) ; v == verdictMisbehave {
2023-04-07 22:41:00 +00:00
return verdictMisbehave
}
2024-04-03 19:00:28 +00:00
if v = a . checkAllClipChannelsAllowed ( attrs . MustStringSlice ( "allowed_clip_channels" , helpers . Ptr ( [ ] string { } ) ) , clips ) ; v == verdictMisbehave {
2023-04-07 22:41:00 +00:00
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
}
2024-01-01 16:52:18 +00:00
//revive:disable-next-line:flag-parameter
2023-04-07 22:41:00 +00:00
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
}
2024-01-01 16:52:18 +00:00
//revive:disable-next-line:flag-parameter
2023-04-07 22:41:00 +00:00
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
}