Skip to content

Commit 986345e

Browse files
authored
Add resolver for Twitch users (#680)
1 parent d5fdaf7 commit 986345e

11 files changed

+327
-6
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Breaking: Go version 1.22.1 is now the minimum required version to build this project. (#667, #671)
66
- Minor: Add playlist support to YouTube resolver. (#597, #601)
77
- Minor: Add YouTube livestream support. (#678)
8+
- Minor: Add Twitch user resolver (e.g. `https://twitch.tv/forsen`). (#680)
89
- Fix: Do not resolve /results using YouTube channel resolver (#616)
910

1011
## 2.0.3

config.yaml

+3
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@
9898
# Cache duration for Wikipedia article links
9999
#wikipedia-article-cache-duration: 1h
100100

101+
# Cache duration for Twitch username links
102+
#twitch-username-cache-duration: 1h
103+
101104
# Minimum level of log message importance required for the log message to not be filtered out.
102105
# Available levels: debug, info, warn, error
103106
# See https://pkg.go.dev/go.uber.org/zap for more information about the logging library we use

internal/mocks/mock_TwitchAPIClient.go

+15
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/resolvers/twitch/clip_resolver_test.go

-4
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,6 @@ func TestClipResolver(t *testing.T) {
9393
},
9494
}
9595

96-
const q = `SELECT value FROM cache WHERE key=$1`
97-
9896
for _, test := range tests {
9997
c.Run(test.label, func(c *qt.C) {
10098
outputBytes, outputError := resolver.Run(ctx, test.inputURL, test.inputReq)
@@ -170,8 +168,6 @@ func TestClipResolver(t *testing.T) {
170168
// },
171169
}
172170

173-
const q = `SELECT value FROM cache WHERE key=$1`
174-
175171
for _, test := range tests {
176172
c.Run(test.label, func(c *qt.C) {
177173
helixClient.EXPECT().GetClips(&helix.ClipsParams{IDs: []string{test.inputSlug}}).Times(1).Return(test.expectedClipResponse, test.expectedClipError)

internal/resolvers/twitch/data_test.go

+16
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,19 @@ var invalidClipSlugs = []string{
3838
"https://twitch.tv/zneix/clip/ImpossibleOilyAlpacaTF2John-jIlgtnSAQ52BThHhifyouseethisvivon",
3939
"https://m.twitch.tv/username/notclip/slug",
4040
}
41+
42+
var validUsers = []string{
43+
"https://twitch.tv/pajlada",
44+
"https://twitch.tv/matthewde",
45+
}
46+
47+
var invalidUsers = []string{
48+
"https://twitch.tv/inventory",
49+
"https://twitch.tv/popout",
50+
"https://twitch.tv/subscriptions",
51+
"https://twitch.tv/videos",
52+
"https://twitch.tv/following",
53+
"https://twitch.tv/directory",
54+
"https://twitch.tv/DIRECTORY",
55+
"https://twitch.tv/moderator",
56+
}

internal/resolvers/twitch/initialize.go

+10
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
type TwitchAPIClient interface {
1919
GetClips(params *helix.ClipsParams) (clip *helix.ClipsResponse, err error)
20+
GetUsers(params *helix.UsersParams) (clip *helix.UsersResponse, err error)
2021
}
2122

2223
const (
@@ -28,12 +29,20 @@ const (
2829
`<b>Created:</b> {{.CreationDate}}<br>` +
2930
`<b>Views:</b> {{.Views}}` +
3031
`</div>`
32+
33+
twitchUserTooltipString = `<div style="text-align: left;">` +
34+
`<b>{{.Name}} - Twitch</b><br>` +
35+
`{{.Description}}<br>` +
36+
`<b>Created:</b> {{.CreatedAt}}<br>` +
37+
`<b>URL:</b> {{.URL}}` +
38+
`</div>`
3139
)
3240

3341
var (
3442
errInvalidTwitchClip = errors.New("invalid Twitch clip link")
3543

3644
twitchClipsTooltip = template.Must(template.New("twitchclipsTooltip").Parse(twitchClipsTooltipString))
45+
twitchUserTooltip = template.Must(template.New("twitchUserTooltip").Parse(twitchUserTooltipString))
3746

3847
domains = map[string]struct{}{
3948
"twitch.tv": {},
@@ -51,5 +60,6 @@ func Initialize(ctx context.Context, cfg config.APIConfig, pool db.Pool, helixCl
5160
return
5261
}
5362

63+
*resolvers = append(*resolvers, NewUserResolver(ctx, cfg, pool, helixClient))
5464
*resolvers = append(*resolvers, NewClipResolver(ctx, cfg, pool, helixClient))
5565
}

internal/resolvers/twitch/initialize_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,6 @@ func TestInitialize(t *testing.T) {
4949
customResolvers := []resolver.Resolver{}
5050
c.Assert(customResolvers, qt.HasLen, 0)
5151
Initialize(ctx, cfg, pool, helixClient, &customResolvers)
52-
c.Assert(customResolvers, qt.HasLen, 1)
52+
c.Assert(customResolvers, qt.HasLen, 2)
5353
})
5454
}
+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package twitch
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
"strings"
10+
"time"
11+
12+
"github.com/Chatterino/api/internal/logger"
13+
"github.com/Chatterino/api/pkg/cache"
14+
"github.com/Chatterino/api/pkg/humanize"
15+
"github.com/Chatterino/api/pkg/resolver"
16+
"github.com/nicklaw5/helix"
17+
)
18+
19+
type twitchUserTooltipData struct {
20+
Name string
21+
CreatedAt string
22+
Description string
23+
URL string
24+
}
25+
26+
type UserLoader struct {
27+
helixAPI TwitchAPIClient
28+
}
29+
30+
func (l *UserLoader) Load(ctx context.Context, login string, r *http.Request) (*resolver.Response, time.Duration, error) {
31+
log := logger.FromContext(ctx)
32+
33+
log.Debugw("[Twitch] Get user",
34+
"login", login,
35+
)
36+
37+
response, err := l.helixAPI.GetUsers(&helix.UsersParams{Logins: []string{login}})
38+
if err != nil {
39+
log.Errorw("[Twitch] Error getting user",
40+
"login", login,
41+
"error", err,
42+
)
43+
44+
return resolver.Errorf("Twitch user load error: %s", err)
45+
}
46+
47+
if len(response.Data.Users) != 1 {
48+
return nil, cache.NoSpecialDur, resolver.ErrDontHandle
49+
}
50+
51+
var user = response.Data.Users[0]
52+
53+
var name string
54+
if strings.ToLower(user.DisplayName) == login {
55+
name = user.DisplayName
56+
} else {
57+
name = fmt.Sprintf("%s (%s)", user.DisplayName, user.Login)
58+
}
59+
60+
data := twitchUserTooltipData{
61+
Name: name,
62+
CreatedAt: humanize.CreationDate(user.CreatedAt.Time),
63+
Description: user.Description,
64+
URL: fmt.Sprintf("https://twitch.tv/%s", user.Login),
65+
}
66+
67+
var tooltip bytes.Buffer
68+
if err := twitchUserTooltip.Execute(&tooltip, data); err != nil {
69+
return resolver.Errorf("Twitch user template error: %s", err)
70+
}
71+
72+
return &resolver.Response{
73+
Status: 200,
74+
Tooltip: url.PathEscape(tooltip.String()),
75+
Thumbnail: user.ProfileImageURL,
76+
}, cache.NoSpecialDur, nil
77+
}
+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package twitch
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/url"
7+
"regexp"
8+
"strings"
9+
10+
"github.com/Chatterino/api/internal/db"
11+
"github.com/Chatterino/api/pkg/cache"
12+
"github.com/Chatterino/api/pkg/config"
13+
"github.com/Chatterino/api/pkg/resolver"
14+
"github.com/Chatterino/api/pkg/utils"
15+
)
16+
17+
var userRegex = regexp.MustCompile(`^\/([a-zA-Z0-9_]+)$`)
18+
var ignoredUsers = []string{
19+
"inventory",
20+
"popout",
21+
"subscriptions",
22+
"videos",
23+
"following",
24+
"directory",
25+
"moderator",
26+
}
27+
28+
type UserResolver struct {
29+
userCache cache.Cache
30+
}
31+
32+
func (r *UserResolver) Check(ctx context.Context, url *url.URL) (context.Context, bool) {
33+
if !utils.IsDomain(url, "twitch.tv") {
34+
return ctx, false
35+
}
36+
37+
userMatch := userRegex.FindStringSubmatch(url.Path)
38+
if len(userMatch) != 2 {
39+
return ctx, false
40+
}
41+
42+
for _, ignoredUser := range ignoredUsers {
43+
if ignoredUser == strings.ToLower(userMatch[1]) {
44+
return ctx, false
45+
}
46+
}
47+
48+
return ctx, true
49+
}
50+
51+
func (r *UserResolver) Run(ctx context.Context, url *url.URL, req *http.Request) (*cache.Response, error) {
52+
return r.userCache.Get(ctx, strings.ToLower(strings.TrimLeft(url.Path, "/")), req)
53+
}
54+
55+
func (r *UserResolver) Name() string {
56+
return "twitch:user"
57+
}
58+
59+
func NewUserResolver(ctx context.Context, cfg config.APIConfig, pool db.Pool, helixAPI TwitchAPIClient) *UserResolver {
60+
userLoader := &UserLoader{helixAPI: helixAPI}
61+
62+
r := &UserResolver{
63+
userCache: cache.NewPostgreSQLCache(ctx, cfg, pool, cache.NewPrefixKeyProvider("twitch:user"),
64+
resolver.NewResponseMarshaller(userLoader), cfg.TwitchUsernameCacheDuration),
65+
}
66+
67+
return r
68+
}

0 commit comments

Comments
 (0)