diff --git a/docs/content/configuration/actors.md b/docs/content/configuration/actors.md index 1a6ed81..3b83a99 100644 --- a/docs/content/configuration/actors.md +++ b/docs/content/configuration/actors.md @@ -127,6 +127,71 @@ Delete message which caused the rule to be executed # Does not have configuration attributes ``` +## Discord Message-Webhook + +Sends a message to a Discord Web-hook + +```yaml +- type: discordhook + attributes: + # URL to send the POST request to + # Optional: false + # Type: string + hook_url: "" + # Overwrites the username set in the webhook configuration + # Optional: true + # Type: string (Supports Templating) + username: "" + # Overwrites the avatar set in the webhook configuration + # Optional: true + # Type: string (Supports Templating) + avatar_url: "" + # Message content to send to the web-hook (this must be set if embed is disabled) + # Optional: true + # Type: string (Supports Templating) + content: "" + # Whether to include the embed in the post + # Optional: true + # Type: bool + add_embed: false + # Title of the embed + # Optional: true + # Type: string (Supports Templating) + embed_title: "" + # Description of the embed + # Optional: true + # Type: string (Supports Templating) + embed_description: "" + # URL the title should link to + # Optional: true + # Type: string (Supports Templating) + embed_url: "" + # URL of the big image displayed in the embed + # Optional: true + # Type: string (Supports Templating) + embed_image: "" + # URL of the small image displayed in the embed + # Optional: true + # Type: string (Supports Templating) + embed_thumbnail: "" + # Name of the post author (if empty all other author-fields are ignored) + # Optional: true + # Type: string (Supports Templating) + embed_author_name: "" + # URL the author name should link to + # Optional: true + # Type: string (Supports Templating) + embed_author_url: "" + # URL of the author avatar + # Optional: true + # Type: string (Supports Templating) + embed_author_icon_url: "" + # Fields to display in the embed (must yield valid JSON: `[{"name": "", "value": "", "inline": false}]`) + # Optional: true + # Type: string (Supports Templating) + embed_fields: "" +``` + ## Enforce Link-Protection Uses link- and clip-scanner to detect links / clips and applies link protection as defined @@ -473,6 +538,23 @@ Perform a Twitch-native shoutout user: "" ``` +## Slack Message-Webhook + +Sends a message to a Slack(-compatible) Web-hook + +```yaml +- type: slackhook + attributes: + # URL to send the POST request to + # Optional: false + # Type: string + hook_url: "" + # Text to send to the web-hook + # Optional: false + # Type: string (Supports Templating) + text: "" +``` + ## Stop Execution Stop Rule Execution on Condition diff --git a/docs/content/configuration/rule-examples.md b/docs/content/configuration/rule-examples.md index 55f90b3..d7f8305 100644 --- a/docs/content/configuration/rule-examples.md +++ b/docs/content/configuration/rule-examples.md @@ -73,6 +73,37 @@ title: "Rule Examples" match_message: '^!death' ``` +## Notify Discord when stream is live + +```yaml + - actions: + - type: discordhook + attributes: + add_embed: true + avatar_url: '{{ profileImage .channel }}' + content: | + <@&123456789012345678> {{ displayName (fixUsername .channel) (fixUsername .channel) }} + is now live on https://www.twitch.tv/{{ fixUsername .channel }} - join us! + embed_author_icon_url: '{{ profileImage .channel }}' + embed_author_name: '{{ displayName (fixUsername .channel) (fixUsername .channel) }}' + embed_fields: | + {{ + toJson ( + list + (dict + "name" "Game" + "value" (recentGame .channel)) + ) + }} + embed_image: https://static-cdn.jtvnw.net/previews-ttv/live_user_{{ fixUsername .channel }}-1280x720.jpg + embed_thumbnail: '{{ profileImage .channel }}' + embed_title: '{{ recentTitle .channel }}' + embed_url: https://twitch.tv/{{ fixUsername .channel }} + hook_url: https://discord.com/api/webhooks/[...]/[...] + username: 'Stream-Live: {{ displayName (fixUsername .channel) (fixUsername .channel) }}' + match_event: stream_online +``` + ## Post follow date for an user ```yaml diff --git a/internal/actors/messagehook/actor.go b/internal/actors/messagehook/actor.go new file mode 100644 index 0000000..fa68eb3 --- /dev/null +++ b/internal/actors/messagehook/actor.go @@ -0,0 +1,69 @@ +package messagehook + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "time" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/Luzifer/twitch-bot/v3/plugins" +) + +const ( + postTimeout = 5 * time.Second +) + +var ( + formatMessage plugins.MsgFormatter + + ptrBoolFalse = func(v bool) *bool { return &v }(false) + ptrStringEmpty = func(s string) *string { return &s }("") +) + +func Register(args plugins.RegistrationArguments) error { + formatMessage = args.FormatMessage + + discordActor{}.register(args) + slackCompatibleActor{}.register(args) + + return nil +} + +func sendPayload(hookURL string, payload any, expRespCode int) (preventCooldown bool, err error) { + body := new(bytes.Buffer) + if err = json.NewEncoder(body).Encode(payload); err != nil { + return false, errors.Wrap(err, "marshalling payload") + } + + logrus.WithField("payload", body.String()).Trace("sending webhook payload") + + ctx, cancel := context.WithTimeout(context.Background(), postTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, hookURL, body) + if err != nil { + return false, errors.Wrap(err, "creating request") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, errors.Wrap(err, "executing request") + } + defer resp.Body.Close() + + if resp.StatusCode != expRespCode { + body, err := io.ReadAll(resp.Body) + if err != nil { + body = []byte(errors.Wrap(err, "reading body").Error()) + } + return false, errors.Errorf("unexpected response code %d (Body: %s)", resp.StatusCode, body) + } + + return false, nil +} diff --git a/internal/actors/messagehook/discord.go b/internal/actors/messagehook/discord.go new file mode 100644 index 0000000..7c0d6b7 --- /dev/null +++ b/internal/actors/messagehook/discord.go @@ -0,0 +1,302 @@ +package messagehook + +import ( + "encoding/json" + "net/http" + + "github.com/go-irc/irc" + "github.com/pkg/errors" + + "github.com/Luzifer/twitch-bot/v3/plugins" +) + +type ( + discordActor struct { + plugins.ActorKit + } + + discordPayload struct { + Content string `json:"content"` + Username string `json:"username,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Embeds []discordPayloadEmbed `json:"embeds,omitempty"` + } + + discordPayloadEmbed struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + URL string `json:"url,omitempty"` + Color int64 `json:"color,omitempty"` + Image *discordPayloadEmbedImage `json:"image,omitempty"` + Thumbnail *discordPayloadEmbedImage `json:"thumbnail,omitempty"` + Author *discordPayloadEmbedAuthor `json:"author,omitempty"` + Fields []discordPayloadEmbedField `json:"fields,omitempty"` + } + + discordPayloadEmbedAuthor struct { + Name string `json:"name"` + URL string `json:"url,omitempty"` + IconURL string `json:"icon_url,omitempty"` + } + + discordPayloadEmbedField struct { + Name string `json:"name"` + Value string `json:"value"` + Inline bool `json:"inline"` + } + + discordPayloadEmbedImage struct { + URL string `json:"url"` + } +) + +func (d discordActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { + var payload discordPayload + + if payload.Content, err = formatMessage(attrs.MustString("content", ptrStringEmpty), m, r, eventData); err != nil { + return false, errors.Wrap(err, "parsing content") + } + + if payload.Username, err = formatMessage(attrs.MustString("username", ptrStringEmpty), m, r, eventData); err != nil { + return false, errors.Wrap(err, "parsing username") + } + + if payload.AvatarURL, err = formatMessage(attrs.MustString("avatar_url", ptrStringEmpty), m, r, eventData); err != nil { + return false, errors.Wrap(err, "parsing avatar_url") + } + + if err = d.addEmbed(&payload, m, r, eventData, attrs); err != nil { + return false, err + } + + return sendPayload(attrs.MustString("hook_url", ptrStringEmpty), payload, http.StatusNoContent) +} + +func (discordActor) IsAsync() bool { return false } + +func (discordActor) Name() string { return "discordhook" } + +func (d discordActor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { + if err = d.ValidateRequireNonEmpty(attrs, "hook_url"); err != nil { + return err + } + + if err = d.ValidateRequireValidTemplate(tplValidator, attrs, "content"); err != nil { + return err + } + + if err = d.ValidateRequireValidTemplateIfSet(tplValidator, attrs, "avatar_url", "username"); err != nil { + return err + } + + if !attrs.MustBool("add_embed", ptrBoolFalse) { + // We're not validating the rest if embeds are disabled but in + // this case the content is mandatory + return d.ValidateRequireNonEmpty(attrs, "content") + } + + return d.ValidateRequireValidTemplateIfSet( + tplValidator, attrs, + "embed_title", + "embed_description", + "embed_url", + "embed_image", + "embed_thumbnail", + "embed_author_name", + "embed_author_url", + "embed_author_icon_url", + "embed_fields", + ) +} + +//nolint:gocyclo // It's complex but just a bunch of converters +func (discordActor) addEmbed(payload *discordPayload, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (err error) { + if !attrs.MustBool("add_embed", ptrBoolFalse) { + // No embed? No problem! + return nil + } + + var ( + embed discordPayloadEmbed + sv string + ) + + if embed.Title, err = formatMessage(attrs.MustString("embed_title", ptrStringEmpty), m, r, eventData); err != nil { + return errors.Wrap(err, "parsing embed_title") + } + + if embed.Description, err = formatMessage(attrs.MustString("embed_description", ptrStringEmpty), m, r, eventData); err != nil { + return errors.Wrap(err, "parsing embed_description") + } + + if embed.URL, err = formatMessage(attrs.MustString("embed_url", ptrStringEmpty), m, r, eventData); err != nil { + return errors.Wrap(err, "parsing embed_url") + } + + if sv, err = formatMessage(attrs.MustString("embed_image", ptrStringEmpty), m, r, eventData); err != nil { + return errors.Wrap(err, "parsing embed_image") + } else if sv != "" { + embed.Image = &discordPayloadEmbedImage{URL: sv} + } + + if sv, err = formatMessage(attrs.MustString("embed_thumbnail", ptrStringEmpty), m, r, eventData); err != nil { + return errors.Wrap(err, "parsing embed_thumbnail") + } else if sv != "" { + embed.Thumbnail = &discordPayloadEmbedImage{URL: sv} + } + + if sv, err = formatMessage(attrs.MustString("embed_author_name", ptrStringEmpty), m, r, eventData); err != nil { + return errors.Wrap(err, "parsing embed_author_name") + } else if sv != "" { + embed.Author = &discordPayloadEmbedAuthor{Name: sv} + + if embed.Author.URL, err = formatMessage(attrs.MustString("embed_author_url", ptrStringEmpty), m, r, eventData); err != nil { + return errors.Wrap(err, "parsing embed_author_url") + } + + if embed.Author.IconURL, err = formatMessage(attrs.MustString("embed_author_icon_url", ptrStringEmpty), m, r, eventData); err != nil { + return errors.Wrap(err, "parsing embed_author_icon_url") + } + } + + if sv, err = formatMessage(attrs.MustString("embed_fields", ptrStringEmpty), m, r, eventData); err != nil { + return errors.Wrap(err, "parsing embed_fields") + } else if sv != "" { + var flds []discordPayloadEmbedField + if err = json.Unmarshal([]byte(sv), &flds); err != nil { + return errors.Wrap(err, "unmarshalling embed_fields") + } + + embed.Fields = flds + } + + payload.Embeds = append(payload.Embeds, embed) + return nil +} + +//nolint:funlen // This is just a bunch of field descriptions +func (discordActor) register(args plugins.RegistrationArguments) { + args.RegisterActor("discordhook", func() plugins.Actor { return &discordActor{} }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Sends a message to a Discord Web-hook", + Name: "Discord Message-Webhook", + Type: "discordhook", + + Fields: []plugins.ActionDocumentationField{ + { + Description: "URL to send the POST request to", + Key: "hook_url", + Name: "Hook URL", + Optional: false, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Description: "Overwrites the username set in the webhook configuration", + Key: "username", + Name: "Username", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Description: "Overwrites the avatar set in the webhook configuration", + Key: "avatar_url", + Name: "Avatar URL", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Description: "Message content to send to the web-hook (this must be set if embed is disabled)", + Key: "content", + Name: "Message", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Default: "false", + Description: "Whether to include the embed in the post", + Key: "add_embed", + Name: "Add Embed", + Optional: true, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeBool, + }, + { + Description: "Title of the embed", + Key: "embed_title", + Name: "Embed Title", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Description: "Description of the embed", + Key: "embed_description", + Name: "Embed Description", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Description: "URL the title should link to", + Key: "embed_url", + Name: "Embed URL", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Description: "URL of the big image displayed in the embed", + Key: "embed_image", + Name: "Embed Image URL", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Description: "URL of the small image displayed in the embed", + Key: "embed_thumbnail", + Name: "Embed Thumbnail URL", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Description: "Name of the post author (if empty all other author-fields are ignored)", + Key: "embed_author_name", + Name: "Embed Author Name", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Description: "URL the author name should link to", + Key: "embed_author_url", + Name: "Embed Author URL", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Description: "URL of the author avatar", + Key: "embed_author_icon_url", + Name: "Embed Author Avatar URL", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Description: "Fields to display in the embed (must yield valid JSON: `[{\"name\": \"\", \"value\": \"\", \"inline\": false}]`)", + Key: "embed_fields", + Name: "Embed Fields", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + }, + }) +} diff --git a/internal/actors/messagehook/slack.go b/internal/actors/messagehook/slack.go new file mode 100644 index 0000000..d132248 --- /dev/null +++ b/internal/actors/messagehook/slack.go @@ -0,0 +1,82 @@ +package messagehook + +import ( + "net/http" + "strings" + + "github.com/go-irc/irc" + "github.com/pkg/errors" + + "github.com/Luzifer/twitch-bot/v3/plugins" +) + +type slackCompatibleActor struct { + plugins.ActorKit +} + +func (s slackCompatibleActor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { + text, err := formatMessage(attrs.MustString("text", nil), m, r, eventData) + if err != nil { + return false, errors.Wrap(err, "parsing text") + } + + return sendPayload( + s.fixHookURL(attrs.MustString("hook_url", ptrStringEmpty)), + map[string]string{ + "text": text, + }, + http.StatusOK, + ) +} + +func (slackCompatibleActor) IsAsync() bool { return false } + +func (slackCompatibleActor) Name() string { return "slackhook" } + +func (s slackCompatibleActor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { + if err = s.ValidateRequireNonEmpty(attrs, "hook_url", "text"); err != nil { + return err + } + + return s.ValidateRequireValidTemplate(tplValidator, attrs, "text") +} + +func (slackCompatibleActor) fixHookURL(hookURL string) string { + if strings.HasPrefix(hookURL, "https://discord.com/api/webhooks/") && !strings.HasSuffix(hookURL, "/slack") { + hookURL = strings.Join([]string{ + strings.TrimRight(hookURL, "/"), + "slack", + }, "/") + } + + return hookURL +} + +func (slackCompatibleActor) register(args plugins.RegistrationArguments) { + args.RegisterActor("slackhook", func() plugins.Actor { return &slackCompatibleActor{} }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Sends a message to a Slack(-compatible) Web-hook", + Name: "Slack Message-Webhook", + Type: "slackhook", + + Fields: []plugins.ActionDocumentationField{ + { + Description: "URL to send the POST request to", + Key: "hook_url", + Name: "Hook URL", + Optional: false, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Description: "Text to send to the web-hook", + Key: "text", + Name: "Message", + Optional: false, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + }, + }) +} diff --git a/plugins/actorkit.go b/plugins/actorkit.go new file mode 100644 index 0000000..3923235 --- /dev/null +++ b/plugins/actorkit.go @@ -0,0 +1,71 @@ +package plugins + +import ( + "reflect" + + "github.com/pkg/errors" +) + +type ( + // ActorKit contains some common validation functions to be used + // when implementing actors + ActorKit struct{} +) + +// ValidateRequireNonEmpty checks whether the fields are gettable +// (not returning ErrValueNotSet) and does not contain zero value +// recognized by reflect (to just check whether the field is set +// but allow zero values use HasAll on the FieldCollection) +func (ActorKit) ValidateRequireNonEmpty(attrs *FieldCollection, fields ...string) error { + for _, field := range fields { + v, err := attrs.Any(field) + if err != nil { + return errors.Wrapf(err, "getting field %s", field) + } + + if reflect.ValueOf(v).IsZero() { + return errors.Errorf("field %s has zero-value", field) + } + } + + return nil +} + +// ValidateRequireValidTemplate checks whether fields are gettable +// as strings and do have a template which validates (this does not +// check for empty strings as an empty template is indeed valid) +func (ActorKit) ValidateRequireValidTemplate(tplValidator TemplateValidatorFunc, attrs *FieldCollection, fields ...string) error { + for _, field := range fields { + v, err := attrs.String(field) + if err != nil { + return errors.Wrapf(err, "getting string field %s", field) + } + + if err = tplValidator(v); err != nil { + return errors.Wrapf(err, "validaging template field %s", field) + } + } + + return nil +} + +// ValidateRequireValidTemplateIfSet checks whether the field is +// either not set or a valid template (this does not +// check for empty strings as an empty template is indeed valid) +func (ActorKit) ValidateRequireValidTemplateIfSet(tplValidator TemplateValidatorFunc, attrs *FieldCollection, fields ...string) error { + for _, field := range fields { + v, err := attrs.String(field) + if err != nil { + if errors.Is(err, ErrValueNotSet) { + continue + } + return errors.Wrapf(err, "getting string field %s", field) + } + + if err = tplValidator(v); err != nil { + return errors.Wrapf(err, "validaging template field %s", field) + } + } + + return nil +} diff --git a/plugins/actorkit_test.go b/plugins/actorkit_test.go new file mode 100644 index 0000000..7132de6 --- /dev/null +++ b/plugins/actorkit_test.go @@ -0,0 +1,27 @@ +package plugins + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateRequireNonEmpty(t *testing.T) { + attrs := FieldCollectionFromData(map[string]any{ + "str": "", + "str_v": "valid", + "int": 0, + "int_v": 1, + }) + + for _, field := range []string{"int", "str"} { + errUnset := ActorKit{}.ValidateRequireNonEmpty(attrs, strings.Join([]string{field, "unset"}, "_")) + errInval := ActorKit{}.ValidateRequireNonEmpty(attrs, field) + errValid := ActorKit{}.ValidateRequireNonEmpty(attrs, strings.Join([]string{field, "v"}, "_")) + + assert.Error(t, errUnset) + assert.Error(t, errInval) + assert.NoError(t, errValid) + } +} diff --git a/plugins_core.go b/plugins_core.go index ebe567c..353af84 100644 --- a/plugins_core.go +++ b/plugins_core.go @@ -23,6 +23,7 @@ import ( "github.com/Luzifer/twitch-bot/v3/internal/actors/linkdetector" "github.com/Luzifer/twitch-bot/v3/internal/actors/linkprotect" logActor "github.com/Luzifer/twitch-bot/v3/internal/actors/log" + "github.com/Luzifer/twitch-bot/v3/internal/actors/messagehook" "github.com/Luzifer/twitch-bot/v3/internal/actors/modchannel" "github.com/Luzifer/twitch-bot/v3/internal/actors/nuke" "github.com/Luzifer/twitch-bot/v3/internal/actors/punish" @@ -69,6 +70,7 @@ var ( linkdetector.Register, linkprotect.Register, logActor.Register, + messagehook.Register, modchannel.Register, nuke.Register, punish.Register,