diff --git a/CHANGELOG.md b/CHANGELOG.md index bc54c4bb..7dfa11ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Added link preview support for 7tv emote links. (#155) - Skip lilliput if image is below maxThumbnailSize. (#184) ## 1.2.0 diff --git a/internal/resolvers/default/resolver.go b/internal/resolvers/default/resolver.go index 15ed36b2..65fd8edd 100644 --- a/internal/resolvers/default/resolver.go +++ b/internal/resolvers/default/resolver.go @@ -12,6 +12,7 @@ import ( "github.com/Chatterino/api/internal/resolvers/imgur" "github.com/Chatterino/api/internal/resolvers/livestreamfails" "github.com/Chatterino/api/internal/resolvers/oembed" + "github.com/Chatterino/api/internal/resolvers/seventv" "github.com/Chatterino/api/internal/resolvers/supinic" "github.com/Chatterino/api/internal/resolvers/twitch" "github.com/Chatterino/api/internal/resolvers/twitter" @@ -106,6 +107,7 @@ func New(cfg config.APIConfig) *R { r.customResolvers = append(r.customResolvers, twitter.New(cfg)...) r.customResolvers = append(r.customResolvers, wikipedia.New(cfg)...) r.customResolvers = append(r.customResolvers, youtube.New(cfg)...) + r.customResolvers = append(r.customResolvers, seventv.New(cfg)...) return r } diff --git a/internal/resolvers/seventv/check.go b/internal/resolvers/seventv/check.go new file mode 100644 index 00000000..6153f07e --- /dev/null +++ b/internal/resolvers/seventv/check.go @@ -0,0 +1,15 @@ +package seventv + +import ( + "net/url" + + "github.com/Chatterino/api/pkg/resolver" +) + +func check(url *url.URL) bool { + if match, _ := resolver.MatchesHosts(url, domains); !match { + return false + } + + return emotePathRegex.MatchString(url.Path) +} diff --git a/internal/resolvers/seventv/load.go b/internal/resolvers/seventv/load.go new file mode 100644 index 00000000..cf1b6e13 --- /dev/null +++ b/internal/resolvers/seventv/load.go @@ -0,0 +1,133 @@ +package seventv + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Chatterino/api/pkg/cache" + "github.com/Chatterino/api/pkg/resolver" + "github.com/Chatterino/api/pkg/utils" +) + +func load(emoteHash string, r *http.Request) (interface{}, time.Duration, error) { + log.Println("[SevenTV] GET", emoteHash) + + queryMap := map[string]interface{}{ + "query": ` +query fetchEmote($id: String!) { + emote(id: $id) { + visibility + id + name + owner { + id + display_name + } + } +}`, + "variables": map[string]string{ + "id": emoteHash, + }, + } + + queryBytes, err := json.Marshal(queryMap) + if err != nil { + return &resolver.Response{ + Status: http.StatusInternalServerError, + Message: "SevenTV API request error" + resolver.CleanResponse(err.Error()), + }, cache.NoSpecialDur, nil + } + + // Execute SevenTV API request + resp, err := resolver.RequestPOST(seventvAPIURL, string(queryBytes)) + if err != nil { + return &resolver.Response{ + Status: http.StatusInternalServerError, + Message: "SevenTV API request error" + resolver.CleanResponse(err.Error()), + }, cache.NoSpecialDur, nil + } + defer resp.Body.Close() + + // Read response into a string + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return &resolver.Response{ + Status: http.StatusInternalServerError, + Message: "SevenTV API http body read error " + resolver.CleanResponse(err.Error()), + }, cache.NoSpecialDur, nil + } + + // Error out if the emote wasn't found or something else went wrong with the request + if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusMultipleChoices { + return emoteNotFoundResponse, cache.NoSpecialDur, nil + } + + var jsonResponse EmoteAPIResponse + if err := json.Unmarshal(body, &jsonResponse); err != nil { + return &resolver.Response{ + Status: http.StatusInternalServerError, + Message: "SevenTV API unmarshal error " + resolver.CleanResponse(err.Error()), + }, cache.NoSpecialDur, nil + } + + // API returns Data.Emote as null if the emote wasn't found + if jsonResponse.Data.Emote == nil { + return emoteNotFoundResponse, cache.NoSpecialDur, nil + } + + // Determine type of the emote based on visibility flags + visibility := jsonResponse.Data.Emote.Visibility + var emoteType []string + + if utils.HasBits(visibility, EmoteVisibilityGlobal) { + emoteType = append(emoteType, "Global") + } + + if utils.HasBits(visibility, EmoteVisibilityPrivate) { + emoteType = append(emoteType, "Private") + } + + // Default to Shared emote + if len(emoteType) == 0 { + emoteType = append(emoteType, "Shared") + } + + // Build tooltip data from the API response + data := TooltipData{ + Code: jsonResponse.Data.Emote.Name, + Type: strings.Join(emoteType, " "), + Uploader: jsonResponse.Data.Emote.Owner.DisplayName, + Unlisted: utils.HasBits(visibility, EmoteVisibilityHidden), + } + + // Build a tooltip using the tooltip template (see tooltipTemplate) with the data we massaged above + var tooltip bytes.Buffer + if err := seventvEmoteTemplate.Execute(&tooltip, data); err != nil { + return &resolver.Response{ + Status: http.StatusInternalServerError, + Message: "SevenTV emote template error " + resolver.CleanResponse(err.Error()), + }, cache.NoSpecialDur, nil + } + + // Success + successTooltip := &resolver.Response{ + Status: http.StatusOK, + Tooltip: url.PathEscape(tooltip.String()), + Thumbnail: utils.FormatThumbnailURL(baseURL, r, fmt.Sprintf(thumbnailFormat, emoteHash)), + Link: fmt.Sprintf("https://7tv.app/emotes/%s", emoteHash), + } + + // Hide thumbnail for unlisted or hidden emotes pajaS + if data.Unlisted { + successTooltip.Thumbnail = "" + } + + return successTooltip, cache.NoSpecialDur, nil +} diff --git a/internal/resolvers/seventv/load_test.go b/internal/resolvers/seventv/load_test.go new file mode 100644 index 00000000..31b730d0 --- /dev/null +++ b/internal/resolvers/seventv/load_test.go @@ -0,0 +1,136 @@ +package seventv + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/Chatterino/api/pkg/resolver" + "github.com/go-chi/chi/v5" + + qt "github.com/frankban/quicktest" +) + +var ( + emotes = map[string]EmoteAPIResponse{} +) + +func init() { + emotes["Pajawalk"] = EmoteAPIResponse{ + Data: EmoteAPIResponseData{ + Emote: &EmoteAPIEmote{ + ID: "604281c81ae70f000d47ffd9", + Name: "Pajawalk", + Visibility: EmoteVisibilityPrivate, + Owner: EmoteAPIUser{ + ID: "603d10e496832ffa787ca53c", + DisplayName: "durado_", + }, + }, + }, + } + + emotes["Hidden"] = EmoteAPIResponse{ + Data: EmoteAPIResponseData{ + Emote: &EmoteAPIEmote{ + ID: "604281c81ae70f000d47ffd9", + Name: "Hidden", + Visibility: EmoteVisibilityPrivate | EmoteVisibilityHidden, + Owner: EmoteAPIUser{ + ID: "603d10e496832ffa787ca53c", + DisplayName: "durado_", + }, + }, + }, + } +} + +func TestFoo(t *testing.T) { + c := qt.New(t) + + r := chi.NewRouter() + r.Post("/v2/gql", func(w http.ResponseWriter, r *http.Request) { + type gqlQuery struct { + Query string `json:"string"` + Variables map[string]string `json:"variables"` + } + + var q gqlQuery + + xd, _ := ioutil.ReadAll(r.Body) + err := json.Unmarshal(xd, &q) + if err != nil { + panic(err) + } + if response, ok := emotes[q.Variables["id"]]; ok { + b, _ := json.Marshal(&response) + + w.Header().Set("Content-Type", "application/json") + w.Write(b) + return + } + + // TODO: return 404 + }) + ts := httptest.NewServer(r) + defer ts.Close() + seventvAPIURL = ts.URL + "/v2/gql" + + type tTest struct { + emoteHash string + expectedTooltip string + } + + tests := []tTest{ + { + emoteHash: "Pajawalk", + expectedTooltip: `