diff --git a/docs/content/configuration/actors.md b/docs/content/configuration/actors.md index 772bc82..1a6ed81 100644 --- a/docs/content/configuration/actors.md +++ b/docs/content/configuration/actors.md @@ -63,6 +63,27 @@ Start Commercial duration: "" ``` +## Create Clip + +Triggers the creation of a Clip from the given channel owned by the creator (subsequent actions can use variables `create_clip_slug` and `create_clip_edit_url`) + +```yaml +- type: clip + attributes: + # Channel to create the clip from, defaults to the channel of the event / message + # Optional: true + # Type: string (Supports Templating) + channel: "" + # User which should trigger and therefore own the clip (must have given clips:edit permission to the bot in extended permissions!), defaults to the value of `channel` + # Optional: true + # Type: string (Supports Templating) + creator: "" + # Whether to add an artificial delay before creating the clip + # Optional: true + # Type: bool + add_delay: false +``` + ## Custom Event Create a custom event diff --git a/internal/actors/clip/actor.go b/internal/actors/clip/actor.go new file mode 100644 index 0000000..f09b40f --- /dev/null +++ b/internal/actors/clip/actor.go @@ -0,0 +1,118 @@ +package clip + +import ( + "context" + "fmt" + + "github.com/go-irc/irc" + "github.com/pkg/errors" + + "github.com/Luzifer/twitch-bot/v3/pkg/twitch" + "github.com/Luzifer/twitch-bot/v3/plugins" +) + +const actorName = "clip" + +var ( + formatMessage plugins.MsgFormatter + hasPerm plugins.ChannelPermissionCheckFunc + tcGetter func(string) (*twitch.Client, error) + + ptrBoolFalse = func(v bool) *bool { return &v }(false) + ptrStringEmpty = func(s string) *string { return &s }("") +) + +func Register(args plugins.RegistrationArguments) error { + formatMessage = args.FormatMessage + hasPerm = args.HasPermissionForChannel + tcGetter = args.GetTwitchClientForChannel + + args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Triggers the creation of a Clip from the given channel owned by the creator (subsequent actions can use variables `create_clip_slug` and `create_clip_edit_url`)", + Name: "Create Clip", + Type: actorName, + + Fields: []plugins.ActionDocumentationField{ + { + Description: "Channel to create the clip from, defaults to the channel of the event / message", + Key: "channel", + Name: "Channel", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Description: fmt.Sprintf("User which should trigger and therefore own the clip (must have given %s permission to the bot in extended permissions!), defaults to the value of `channel`", twitch.ScopeClipsEdit), + Key: "creator", + Name: "Creator", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Default: "false", + Description: "Whether to add an artificial delay before creating the clip", + Key: "add_delay", + Name: "Add Delay", + Optional: true, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeBool, + }, + }, + }) + + return nil +} + +type actor struct{} + +func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { + channel := plugins.DeriveChannel(m, eventData) + if channel, err = formatMessage(attrs.MustString("channel", &channel), m, r, eventData); err != nil { + return false, errors.Wrap(err, "parsing channel") + } + + creator := channel + if creator, err = formatMessage(attrs.MustString("creator", &creator), m, r, eventData); err != nil { + return false, errors.Wrap(err, "parsing creator") + } + + canCreate, err := hasPerm(creator, twitch.ScopeClipsEdit) + if err != nil { + return false, errors.Wrap(err, "checking for required permission") + } + + if !canCreate { + return false, errors.Errorf("creator has not given %s permission", twitch.ScopeClipsEdit) + } + + tc, err := tcGetter(creator) + if err != nil { + return false, errors.Wrapf(err, "getting Twitch client for %q", creator) + } + + clipInfo, err := tc.CreateClip(context.TODO(), channel, attrs.MustBool("add_delay", ptrBoolFalse)) + if err != nil { + return false, errors.Wrap(err, "creating clip") + } + + eventData.Set("create_clip_slug", clipInfo.ID) + eventData.Set("create_clip_edit_url", clipInfo.EditURL) + return false, nil +} + +func (actor) IsAsync() bool { return false } + +func (actor) Name() string { return actorName } + +func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *plugins.FieldCollection) (err error) { + for _, field := range []string{"channel", "creator"} { + if err = tplValidator(attrs.MustString(field, ptrStringEmpty)); err != nil { + return errors.Wrapf(err, "validating %s template", field) + } + } + + return nil +} diff --git a/pkg/twitch/clips.go b/pkg/twitch/clips.go index 987c526..5e263b3 100644 --- a/pkg/twitch/clips.go +++ b/pkg/twitch/clips.go @@ -30,8 +30,45 @@ type ( Duration float64 `json:"duration"` VodOffset int64 `json:"vod_offset"` } + + CreateClipResponse struct { + ID string `json:"id"` + EditURL string `json:"edit_url"` + } ) +// CreateClip triggers the creation of a clip in the given channel. +// If addDelay is true an artificial delay will be added (for +// broadcasters who trigger this function already knowing something +// will happen but not yet visible in stream). +func (c *Client) CreateClip(ctx context.Context, channel string, addDelay bool) (ccr CreateClipResponse, err error) { + id, err := c.GetIDForUsername(channel) + if err != nil { + return ccr, errors.Wrap(err, "getting ID for channel") + } + + var payload struct { + Data []CreateClipResponse + } + + if err := c.Request(ClientRequestOpts{ + AuthType: AuthTypeBearerToken, + Context: ctx, + Method: http.MethodPost, + OKStatus: http.StatusAccepted, + Out: &payload, + URL: fmt.Sprintf("https://api.twitch.tv/helix/clips?broadcaster_id=%s&has_delay=%v", id, addDelay), + }); err != nil { + return ccr, errors.Wrap(err, "triggering clip create") + } + + if l := len(payload.Data); l != 1 { + return ccr, errors.Errorf("unexpected number of results returned: %d", l) + } + + return payload.Data[0], nil +} + // GetClipByID gets a video clip that were captured from streams by // its ID (slug in the URL) func (c *Client) GetClipByID(ctx context.Context, clipID string) (ClipInfo, error) { diff --git a/pkg/twitch/scopes.go b/pkg/twitch/scopes.go index 4620a07..c58276b 100644 --- a/pkg/twitch/scopes.go +++ b/pkg/twitch/scopes.go @@ -10,6 +10,7 @@ const ( ScopeChannelManageRaids = "channel:manage:raids" ScopeChannelManageRedemptions = "channel:manage:redemptions" ScopeChannelManageVIPS = "channel:manage:vips" + ScopeClipsEdit = "clips:edit" ScopeChannelManageWhispers = "user:manage:whispers" ScopeChannelReadPolls = "channel:read:polls" ScopeChannelReadRedemptions = "channel:read:redemptions" diff --git a/plugins_core.go b/plugins_core.go index bdb2416..ebe567c 100644 --- a/plugins_core.go +++ b/plugins_core.go @@ -12,6 +12,7 @@ import ( "github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/twitch-bot/v3/internal/actors/announce" "github.com/Luzifer/twitch-bot/v3/internal/actors/ban" + "github.com/Luzifer/twitch-bot/v3/internal/actors/clip" "github.com/Luzifer/twitch-bot/v3/internal/actors/clipdetector" "github.com/Luzifer/twitch-bot/v3/internal/actors/commercial" "github.com/Luzifer/twitch-bot/v3/internal/actors/counter" @@ -57,6 +58,7 @@ var ( // Actors announce.Register, ban.Register, + clip.Register, clipdetector.Register, commercial.Register, counter.Register, diff --git a/scopes.go b/scopes.go index 1daf7ce..5de2cb9 100644 --- a/scopes.go +++ b/scopes.go @@ -12,6 +12,7 @@ var ( twitch.ScopeChannelManageVIPS: "manage VIPs", twitch.ScopeChannelReadRedemptions: "see channel-point redemptions", twitch.ScopeChannelReadSubscriptions: "see subscribed users / sub count / points", + twitch.ScopeClipsEdit: "create clips on behalf of this user", twitch.ScopeModeratorReadFollowers: "see who follows this channel", twitch.ScopeModeratorReadShoutouts: "see shoutouts created / received", }