Compare commits

..

6 Commits

Author SHA1 Message Date
167ec07096
Merge a96d1db9a0 into 710783aaf7 2024-09-15 13:05:39 +02:00
710783aaf7
[core] Fix: StreamMarker contained wrong ID format
as of broken documentation

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-09-13 00:08:41 +02:00
19038dbc6e
[templating] Add currentVOD function
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-09-11 20:30:55 +02:00
740a71a173
[marker] Add marker info to actor result
in order to enable rules to access i.e. the position of the marker

Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-09-11 14:23:42 +02:00
e0a8ce3684
[linkcheck] Fix: Replace static (deprecated) user-agent list
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-09-05 12:50:58 +02:00
5a8459cedc
[marker] Implement actor to create stream markers
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-09-04 00:06:55 +02:00
14 changed files with 471 additions and 96 deletions

View File

@ -40,9 +40,10 @@ node_modules:
# --- Tools
update_ua_list:
# User-Agents provided by https://www.useragents.me/
curl -sSf https://www.useragents.me/api | jq -r '.data[].ua' | grep -v 'Trident' >internal/linkcheck/user-agents.txt
update-chrome-major:
sed -i -E \
's/chromeMajor = [0-9]+/chromeMajor = $(shell curl -sSf https://lv.luzifer.io/v1/catalog/google-chrome/stable/version | cut -d '.' -f 1)/' \
internal/linkcheck/useragent.go
gh-workflow:
bash ci/create-workflow.sh

View File

@ -84,6 +84,23 @@ Triggers the creation of a Clip from the given channel owned by the creator (sub
add_delay: false
```
## Create Marker
Creates a marker on the currently running stream of the given channel. The marker will be created on behalf of the channel owner and requires matching scope. (Subsequent actions can use variable `marker` to access marker details.)
```yaml
- type: marker
attributes:
# Channel to create the marker in, defaults to the channel of the event / message
# Optional: true
# Type: string (Supports Templating)
channel: ""
# Description of the marker to create (up to 140 chars)
# Optional: true
# Type: string (Supports Templating)
description: ""
```
## Custom Event
Create a custom event

View File

@ -165,6 +165,19 @@ Example:
* 1 6
```
### `currentVOD`
Returns the VOD of the currently running stream in the given channel (causes an error if no current stream / VOD is found)
Syntax: `currentVOD <username>`
Example:
```
# {{ currentVOD .channel }}
* https://www.twitch.tv/videos/123456789
```
### `displayName`
Returns the display name the specified user set for themselves
@ -467,7 +480,7 @@ Example:
```
# Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}%
< Your int this hour: 41%
< Your int this hour: 23%
```
### `spotifyCurrentPlaying`

View File

@ -116,7 +116,7 @@ SocketMessage received for every event and passed to the new `(eventObj) => { ..
| Name | Type | Description |
| --- | --- | --- |
| [event_id] | <code>Number</code> | UID of the event used to re-trigger an event |
| [event_id] | <code>String</code> | UID of the event used to re-trigger an event |
| [is_live] | <code>Boolean</code> | Whether the event was sent through a replay (false) or occurred live (true) |
| [reason] | <code>String</code> | Reason of this message (one of `bulk-replay`, `live-event`, `single-replay`) |
| [time] | <code>String</code> | RFC3339 timestamp of the event |

View File

@ -0,0 +1,114 @@
// Package marker contains an actor to create markers on the current
// running stream
package marker
import (
"context"
"fmt"
"strings"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
"gopkg.in/irc.v4"
)
const actorName = "marker"
var (
formatMessage plugins.MsgFormatter
hasPerm plugins.ChannelPermissionCheckFunc
tcGetter func(string) (*twitch.Client, error)
)
// Register provides the plugins.RegisterFunc
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: "Creates a marker on the currently running stream of the given channel. The marker will be created on behalf of the channel owner and requires matching scope. (Subsequent actions can use variable `marker` to access marker details.)",
Name: "Create Marker",
Type: actorName,
Fields: []plugins.ActionDocumentationField{
{
Description: "Channel to create the marker in, defaults to the channel of the event / message",
Key: "channel",
Name: "Channel",
Optional: true,
SupportTemplate: true,
Type: plugins.ActionDocumentationFieldTypeString,
},
{
Description: "Description of the marker to create (up to 140 chars)",
Key: "description",
Name: "Description",
Optional: true,
SupportTemplate: true,
Type: plugins.ActionDocumentationFieldTypeString,
},
},
})
return nil
}
type actor struct{}
func (actor) Execute(_ *irc.Client, m *irc.Message, r *plugins.Rule, eventData *fieldcollection.FieldCollection, attrs *fieldcollection.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, fmt.Errorf("parsing channel: %w", err)
}
var description string
if description, err = formatMessage(attrs.MustString("description", &description), m, r, eventData); err != nil {
return false, fmt.Errorf("parsing description: %w", err)
}
channel = strings.TrimLeft(channel, "#")
canCreate, err := hasPerm(channel, twitch.ScopeChannelManageBroadcast)
if err != nil {
return false, fmt.Errorf("checking for required permission: %w", err)
}
if !canCreate {
return false, fmt.Errorf("creator has not given %s permission", twitch.ScopeChannelManageBroadcast)
}
tc, err := tcGetter(channel)
if err != nil {
return false, fmt.Errorf("getting Twitch client for %q: %w", channel, err)
}
var marker twitch.StreamMarkerInfo
if marker, err = tc.CreateStreamMarker(context.TODO(), description); err != nil {
return false, fmt.Errorf("creating marker: %w", err)
}
eventData.Set("marker", marker)
return false, nil
}
func (actor) IsAsync() bool { return false }
func (actor) Name() string { return actorName }
func (actor) Validate(tplValidator plugins.TemplateValidatorFunc, attrs *fieldcollection.FieldCollection) (err error) {
if err = attrs.ValidateSchema(
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "channel", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.CanHaveField(fieldcollection.SchemaField{Name: "description", NonEmpty: true, Type: fieldcollection.SchemaFieldTypeString}),
fieldcollection.MustHaveNoUnknowFields,
helpers.SchemaValidateTemplateField(tplValidator, "channel", "description"),
); err != nil {
return fmt.Errorf("validating attributes: %w", err)
}
return nil
}

View File

@ -57,13 +57,12 @@ func TestScanForLinks(t *testing.T) {
t.SkipNow()
}
c := New()
for _, testCase := range []struct {
Heuristic bool
Message string
ExpectedLinks []string
ExpectedContains bool
TraceStack bool
}{
// Case: full URL is present in the message
{
@ -183,6 +182,13 @@ func TestScanForLinks(t *testing.T) {
{Heuristic: false, Message: "Hey btw. es kann sein, dass", ExpectedLinks: nil},
} {
t.Run(fmt.Sprintf("h:%v lc:%d m:%s", testCase.Heuristic, len(testCase.ExpectedLinks), testCase.Message), func(t *testing.T) {
var c *Checker
if testCase.TraceStack {
c = New(withResolver(newResolver(resolverPoolSize, withTesting(t))))
} else {
c = New()
}
var linksFound []string
if testCase.Heuristic {
linksFound = c.HeuristicScanForLinks(testCase.Message)
@ -209,23 +215,3 @@ func TestScanForLinks(t *testing.T) {
})
}
}
func TestUserAgentListNotEmpty(t *testing.T) {
if len(defaultUserAgents) == 0 {
t.Fatal("found empty user-agent list")
}
}
func TestUserAgentRandomizer(t *testing.T) {
uas := map[string]int{}
for i := 0; i < 10; i++ {
uas[defaultResolver.userAgent()]++
}
for _, c := range uas {
assert.Less(t, c, 10)
}
assert.Equal(t, 0, uas[""]) // there should be no empty UA
}

View File

@ -2,16 +2,14 @@ package linkcheck
import (
"context"
"crypto/rand"
_ "embed"
"io"
"math/big"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/sirupsen/logrus"
@ -30,6 +28,8 @@ type (
resolver struct {
resolverC chan resolverQueueEntry
skipValidation bool
t *testing.T
}
resolverQueueEntry struct {
@ -40,20 +40,12 @@ type (
)
var (
defaultUserAgents = []string{}
linkTest = regexp.MustCompile(`(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]`)
numericHost = regexp.MustCompile(`^(?:[0-9]+\.)*[0-9]+(?::[0-9]+)?$`)
//go:embed user-agents.txt
uaList string
defaultResolver = newResolver(resolverPoolSize)
)
func init() {
defaultUserAgents = strings.Split(strings.TrimSpace(uaList), "\n")
}
func newResolver(poolSize int, opts ...func(*resolver)) *resolver {
r := &resolver{
resolverC: make(chan resolverQueueEntry),
@ -74,6 +66,10 @@ func withSkipVerify() func(*resolver) {
return func(r *resolver) { r.skipValidation = true }
}
func withTesting(t *testing.T) func(*resolver) {
return func(r *resolver) { r.t = t }
}
func (r resolver) Resolve(qe resolverQueueEntry) {
qe.WaitGroup.Add(1)
r.resolverC <- qe
@ -87,8 +83,8 @@ func (resolver) getJar() *cookiejar.Jar {
// resolveFinal takes a link and looks up the final destination of
// that link after all redirects were followed
//
//nolint:gocyclo
func (r resolver) resolveFinal(link string, cookieJar *cookiejar.Jar, callStack *stack, userAgent string) string {
//nolint:funlen,gocyclo
func (r resolver) resolveFinal(link string, cookieJar *cookiejar.Jar, callStack *stack) string {
if !linkTest.MatchString(link) && !r.skipValidation {
return ""
}
@ -131,12 +127,19 @@ func (r resolver) resolveFinal(link string, cookieJar *cookiejar.Jar, callStack
// Sanitize host: Trailing dots are valid but not required
u.Host = strings.TrimRight(u.Host, ".")
if r.t != nil {
r.t.Logf("resolving link: link=%q jar_c=%#v stack_c=%d stack_h=%d",
link, len(cookieJar.Cookies(u)), callStack.Count(link), callStack.Height())
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return ""
}
req.Header.Set("User-Agent", userAgent)
for k, v := range generateUserAgentHeaders() {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
@ -156,7 +159,7 @@ func (r resolver) resolveFinal(link string, cookieJar *cookiejar.Jar, callStack
}
target := r.resolveReference(u, tu)
callStack.Visit(link)
return r.resolveFinal(target, cookieJar, callStack, userAgent)
return r.resolveFinal(target, cookieJar, callStack)
}
// We got a response, it's no redirect, lets check for in-document stuff
@ -173,14 +176,14 @@ func (r resolver) resolveFinal(link string, cookieJar *cookiejar.Jar, callStack
}
target := r.resolveReference(u, tu)
callStack.Visit(link)
return r.resolveFinal(target, cookieJar, callStack, userAgent)
return r.resolveFinal(target, cookieJar, callStack)
}
if resp.Header.Get("Set-Cookie") != "" {
// A new cookie was set, lets refresh the page once to see if stuff
// changes with that new cookie
callStack.Visit(link)
return r.resolveFinal(u.String(), cookieJar, callStack, userAgent)
return r.resolveFinal(u.String(), cookieJar, callStack)
}
// We had no in-document redirects: we count this as a success
@ -226,14 +229,9 @@ func (resolver) resolveReference(origin *url.URL, loc *url.URL) string {
func (r resolver) runResolver() {
for qe := range r.resolverC {
if link := r.resolveFinal(qe.Link, r.getJar(), &stack{}, r.userAgent()); link != "" {
if link := r.resolveFinal(qe.Link, r.getJar(), &stack{}); link != "" {
qe.Callback(link)
}
qe.WaitGroup.Done()
}
}
func (resolver) userAgent() string {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(defaultUserAgents))))
return defaultUserAgents[n.Int64()]
}

View File

@ -1,43 +0,0 @@
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36 Edg/110.0.1587.63
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36
Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36 Edg/110.0.1587.57
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 OPR/95.0.0.0
Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36
Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36 Edg/110.0.1587.41
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36 Edg/110.0.1587.56
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Safari/537.36 Edg/103.0.1264.37
Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.85 Safari/537.36 Edg/90.0.818.46
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36 Edg/110.0.1587.50
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Whale/3.19.166.16 Safari/537.36
Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 Edg/108.0.1462.76
Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36 Edg/110.0.1587.46
Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0
Mozilla/5.0 (Windows NT 6.3; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0
Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36 Core/1.94.192.400 QQBrowser/11.5.5250.400
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.78
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36
Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 OPR/95.0.0.0
Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36 Edg/110.0.1587.63
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36 Edg/92.0.902.67
Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:108.0) Gecko/20100101 Firefox/108.0
Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36
Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/18.17763
Mozilla/5.0 (X11; Linux x86_64; rv:108.0) Gecko/20100101 Firefox/108.0
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36
Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36 Edg/110.0.1587.63
Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/111.0
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 Edg/108.0.1462.54
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.61
Mozilla/5.0 (Windows NT 10.0; rv:109.0) Gecko/20100101 Firefox/110.0
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.70

View File

@ -0,0 +1,38 @@
package linkcheck
import (
"fmt"
)
const (
chromeMajor = 128
webkitMajor = 537
webkitMinor = 36
)
// generateUserAgent resembles the Chrome user agent generation as
// closely as possible in order to blend into the crowd of browsers
//
// https://github.com/chromium/chromium/blob/58e23d958ee8d2bb4b085c843a18eb28b9da17da/content/common/user_agent.cc
func generateUserAgentHeaders() map[string]string {
return map[string]string{
// New UA hints method
"Sec-CH-UA": fmt.Sprintf(
`"Chromium";v="%[1]d", "Not;A=Brand";v="24", "Google Chrome";v="%[1]d"`,
chromeMajor,
),
// Not a mobile browser
"Sec-CH-UA-Mobile": "?0",
// We're always Windows
"Sec-CH-UA-Platform": "Windows",
// "old" user-agent
"User-Agent": fmt.Sprintf(
"Mozilla/5.0 (%s) AppleWebKit/537.36 (KHTML, like Gecko) %s Safari/537.36",
"Windows NT 10.0; Win64; x64", // We're always Windows 10 / 11 on x64
fmt.Sprintf("Chrome/%d.0.0.0", chromeMajor), // UA-Reduction enabled
),
}
}

View File

@ -0,0 +1,50 @@
package twitch
import (
"context"
"fmt"
"strings"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
func init() {
regFn = append(
regFn,
tplTwitchCurrentVOD,
)
}
func tplTwitchCurrentVOD(args plugins.RegistrationArguments) {
args.RegisterTemplateFunction("currentVOD", plugins.GenericTemplateFunctionGetter(func(username string) (string, error) {
si, err := args.GetTwitchClient().GetCurrentStreamInfo(context.Background(), strings.TrimLeft(username, "#"))
if err != nil {
return "", fmt.Errorf("getting stream info: %w", err)
}
vids, err := args.GetTwitchClient().GetVideos(context.TODO(), twitch.GetVideoOpts{
UserID: si.UserID,
})
if err != nil {
return "", fmt.Errorf("getting videos: %w", err)
}
for _, v := range vids {
if v.StreamID == nil || *v.StreamID != si.ID {
continue
}
return v.URL, nil
}
return "", fmt.Errorf("no matching VOD found")
}), plugins.TemplateFuncDocumentation{
Description: "Returns the VOD of the currently running stream in the given channel (causes an error if no current stream / VOD is found)",
Syntax: "currentVOD <username>",
Example: &plugins.TemplateFuncDocumentationExample{
Template: `{{ currentVOD .channel }}`,
FakedOutput: "https://www.twitch.tv/videos/123456789",
},
})
}

View File

@ -1,7 +1,9 @@
package twitch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
@ -27,12 +29,59 @@ type (
TagIds []string `json:"tag_ids"` //revive:disable-line:var-naming // Disabled to prevent breaking change
IsMature bool `json:"is_mature"`
}
// StreamMarkerInfo contains information about a marker on a stream
StreamMarkerInfo struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Description string `json:"description"`
PositionSeconds int64 `json:"position_seconds"`
}
)
// ErrNoStreamsFound allows to differntiate between an HTTP error and
// the fact there just is no stream found
var ErrNoStreamsFound = errors.New("no streams found")
// CreateStreamMarker creates a marker for the currently running stream.
// The stream must be live, no VoD, no upload and no re-run.
// The description may be up to 140 chars and can be omitted.
func (c *Client) CreateStreamMarker(ctx context.Context, description string) (marker StreamMarkerInfo, err error) {
body := new(bytes.Buffer)
userID, _, err := c.GetAuthorizedUser(ctx)
if err != nil {
return marker, fmt.Errorf("getting ID for current user: %w", err)
}
if err = json.NewEncoder(body).Encode(struct {
UserID string `json:"user_id"`
Description string `json:"description,omitempty"`
}{
UserID: userID,
Description: description,
}); err != nil {
return marker, fmt.Errorf("encoding payload: %w", err)
}
var payload struct {
Data []StreamMarkerInfo `json:"data"`
}
if err := c.Request(ctx, ClientRequestOpts{
AuthType: AuthTypeBearerToken,
Body: body,
Method: http.MethodPost,
OKStatus: http.StatusOK,
Out: &payload,
URL: "https://api.twitch.tv/helix/streams/markers",
}); err != nil {
return marker, fmt.Errorf("creating marker: %w", err)
}
return payload.Data[0], nil
}
// GetCurrentStreamInfo returns the StreamInfo of the currently running
// stream of the given username
func (c *Client) GetCurrentStreamInfo(ctx context.Context, username string) (*StreamInfo, error) {

150
pkg/twitch/videos.go Normal file
View File

@ -0,0 +1,150 @@
package twitch
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/mitchellh/hashstructure/v2"
)
type (
// GetVideoOpts contain the query parameter for the GetVideos query
//
// See https://dev.twitch.tv/docs/api/reference/#get-videos for details
GetVideoOpts struct {
ID string // Required: Exactly one of ID, UserID, GameID
UserID string // Required: Exactly one of ID, UserID, GameID
GameID string // Required: Exactly one of ID, UserID, GameID
Language string // Optional: Use only with GameID
Period GetVideoOptsPeriod // Optional: Use only with GameID or UserID
Sort GetVideoOptsSort // Optional: Use only with GameID or UserID
Type GetVideoOptsType // Optional: Use only with GameID or UserID
First int64 // Optional: Use only with GameID or UserID
After string // Optional: Use only with UserID
Before string // Optional: Use only with UserID
}
// GetVideoOptsPeriod represents a filter used to filter the list of
// videos by when they were published
GetVideoOptsPeriod string
// GetVideoOptsSort represents the order to sort the returned videos in
GetVideoOptsSort string
// GetVideoOptsType represents a filter used to filter the list of
// videos by the video's type
GetVideoOptsType string
// Video contains information about a published video
Video struct {
ID string `json:"id"`
StreamID *string `json:"stream_id"`
UserID string `json:"user_id"`
UserLogin string `json:"user_login"`
UserName string `json:"user_name"`
Title string `json:"title"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
PublishedAt time.Time `json:"published_at"`
URL string `json:"url"`
ThumbnailURL string `json:"thumbnail_url"`
Viewable string `json:"viewable"`
ViewCount int64 `json:"view_count"`
Language string `json:"language"`
Type string `json:"type"`
Duration string `json:"duration"`
MutedSegments []struct {
Duration int64 `json:"duration"`
Offset int64 `json:"offset"`
} `json:"muted_segments"`
}
)
// List of filters for GetVideoOpts.Period
const (
GetVideoOptsPeriodAll GetVideoOptsPeriod = "all"
GetVideoOptsPeriodDay GetVideoOptsPeriod = "day"
GetVideoOptsPeriodMonth GetVideoOptsPeriod = "month"
GetVideoOptsPeriodWeek GetVideoOptsPeriod = "week"
)
// List of sort options for GetVideoOpts.Sort
const (
GetVideoOptsSortTime GetVideoOptsSort = "time"
GetVideoOptsSortTrending GetVideoOptsSort = "trending"
GetVideoOptsSortViews GetVideoOptsSort = "views"
)
// List of types for GetVideoOpts.Type
const (
GetVideoOptsTypeAll GetVideoOptsType = "all"
GetVideoOptsTypeArchive GetVideoOptsType = "archive"
GetVideoOptsTypeHighlight GetVideoOptsType = "highlight"
GetVideoOptsTypeUpload GetVideoOptsType = "upload"
)
// GetVideos fetches information about one or more published videos
func (c *Client) GetVideos(ctx context.Context, opts GetVideoOpts) (videos []Video, err error) {
optsCacheKey, err := opts.cacheKey()
if err != nil {
return nil, fmt.Errorf("getting opts cache key: %w", err)
}
cacheKey := []string{"currentVideos", optsCacheKey}
if vids := c.apiCache.Get(cacheKey); vids != nil {
return vids.([]Video), nil
}
var payload struct {
Data []Video `json:"data"`
}
if err := c.Request(ctx, ClientRequestOpts{
AuthType: AuthTypeAppAccessToken,
Method: http.MethodGet,
OKStatus: http.StatusOK,
Out: &payload,
URL: fmt.Sprintf("https://api.twitch.tv/helix/videos?%s", opts.queryParams()),
}); err != nil {
return nil, fmt.Errorf("requesting videos: %w", err)
}
// Videos can be changed at any moment, cache for a short period of time
c.apiCache.Set(cacheKey, twitchMinCacheTime, payload.Data)
return payload.Data, nil
}
func (g GetVideoOpts) cacheKey() (string, error) {
h, err := hashstructure.Hash(g, hashstructure.FormatV2, nil)
if err != nil {
return "", fmt.Errorf("hashing opts: %w", err)
}
return strconv.FormatUint(h, 10), nil
}
func (g GetVideoOpts) queryParams() string {
params := url.Values{}
for k, v := range map[string]string{
"id": g.ID,
"user_id": g.UserID,
"game_id": g.GameID,
"language": g.Language,
"period": string(g.Period),
"sort": string(g.Sort),
"type": string(g.Type),
"first": strconv.FormatInt(g.First, 10),
"after": g.After,
"before": g.Before,
} {
if v != "" && v != "0" {
params.Set(k, v)
}
}
return params.Encode()
}

View File

@ -25,6 +25,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/marker"
"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"
@ -78,6 +79,7 @@ var (
linkdetector.Register,
linkprotect.Register,
logActor.Register,
marker.Register,
messagehook.Register,
modchannel.Register,
nuke.Register,

View File

@ -5,7 +5,7 @@ import "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
var (
channelExtendedScopes = map[string]string{
twitch.ScopeChannelEditCommercial: "run commercial",
twitch.ScopeChannelManageBroadcast: "modify category / title",
twitch.ScopeChannelManageBroadcast: "modify category / title, create markers",
twitch.ScopeChannelManagePolls: "manage polls",
twitch.ScopeChannelManagePredictions: "manage predictions",
twitch.ScopeChannelManageRaids: "start raids",