Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added rich tooltips for 7tv emote links #155

Merged
merged 26 commits into from
Jul 3, 2021
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9b1e886
Initial work, added basic 7tv emote resolver support
zneix May 31, 2021
558f08e
Added changelog entry
zneix May 31, 2021
8c61749
Fixed crash on invalid emote links, better query
zneix Jun 5, 2021
bb23275
Implement hiding thumbnails for links of unlisted emotes
zneix Jun 5, 2021
542fa52
Handle emote Type properly
zneix Jun 6, 2021
4704209
Merge branch 'master' into zneix/feature/add-7tv-emote-resolver
zneix Jun 6, 2021
bb0dd7c
Add a clean util for bitwise operations
zneix Jun 6, 2021
dc67aab
Add documentation for newly added util
zneix Jun 6, 2021
05a00bd
Update pkg/resolver/request.go
pajlada Jun 6, 2021
6d09a84
Add tests for utils.HasBits
pajlada Jun 6, 2021
568cd0a
Use GQL variables instead of string formatting for building query
pajlada Jun 6, 2021
bcbb92e
Remove anonymous structs from 7tv model for easier testing
pajlada Jun 6, 2021
2df29e6
Split template up onto separate lines for nicer testing
pajlada Jun 6, 2021
6e4dcda
Add basic tests
pajlada Jun 6, 2021
86728c4
add todos for more tests that could be added
pajlada Jun 6, 2021
20ae1c1
Split host and path matching
pajlada Jun 6, 2021
d87cd45
Remove debug printing
pajlada Jun 6, 2021
2e92187
Update changelog entry
pajlada Jun 6, 2021
3391e52
Remove old gql query variable
zneix Jun 6, 2021
ddce648
Merge remote-tracking branch 'origin/master' into zneix/feature/add-7…
zneix Jun 9, 2021
cccc178
Merge branch 'master' into zneix/feature/add-7tv-emote-resolver
zneix Jun 21, 2021
94e4ece
Proxy thumbnail requests... not really
zneix Jun 21, 2021
714fcb0
Fixed tests not working by 'mocking' an HTTP request
pajlada Jul 3, 2021
15f57a3
Remove merge sign
pajlada Jul 3, 2021
e9f2e97
Merge branch 'master' into zneix/feature/add-7tv-emote-resolver
pajlada Jul 3, 2021
36f1ed3
Merge branch 'master' into zneix/feature/add-7tv-emote-resolver
pajlada Jul 3, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Added support for 7tv emote links. (#155)
pajlada marked this conversation as resolved.
Show resolved Hide resolved
- Added support for customizable oEmbed resolving for websites with the `providers.json` file. See [`data/oembed/providers.json`](data/oembed/providers.json). Three new environment variables can be set. See [`internal/resolvers/oembed/README.md`](internal/resolvers/oembed/README.md) (#139, #152)
- Breaking: Environment variable `CHATTERINO_API_CACHE_TWITCH_CLIENT_ID` was renamed to `CHATTERINO_API_TWITCH_CLIENT_ID`. (#144)
- Dev, Breaking: Replaced `dankeroni/gotwitch` with `nicklaw5/helix`. This change requires you to add new environment variable: `CHATTERINO_API_TWITCH_CLIENT_SECRET` - it's a client secret generated for your Twitch application.
Expand Down
2 changes: 2 additions & 0 deletions internal/resolvers/default/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -84,6 +85,7 @@ func New(baseURL string) *R {
r.customResolvers = append(r.customResolvers, wikipedia.New()...)
r.customResolvers = append(r.customResolvers, livestreamfails.New()...)
r.customResolvers = append(r.customResolvers, oembed.New()...)
r.customResolvers = append(r.customResolvers, seventv.New()...)

return r
}
Expand Down
10 changes: 10 additions & 0 deletions internal/resolvers/seventv/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package seventv

import (
"net/url"
"strings"
)

func check(url *url.URL) bool {
return seventvEmoteURLRegex.MatchString(strings.ToLower(url.Host) + url.Path)
}
108 changes: 108 additions & 0 deletions internal/resolvers/seventv/load.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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)

// Execute SevenTV API request
resp, err := resolver.RequestPOST(seventvAPIURL, fmt.Sprintf(gqlQueryEmotes, emoteHash))
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
fmt.Println(jsonResponse.Data.Emote)
pajlada marked this conversation as resolved.
Show resolved Hide resolved
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: 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
}
39 changes: 39 additions & 0 deletions internal/resolvers/seventv/model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package seventv

type EmoteAPIUser struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
}

type EmoteAPIEmote struct {
ID string `json:"id"`
Name string `json:"name"`
Visibility int32 `json:"visibility"`
Owner EmoteAPIUser `json:"owner"`
}

type EmoteAPIResponse struct {
Data struct {
Emote *EmoteAPIEmote `json:"emote,omitempty"`
} `json:"data"`
}

type TooltipData struct {
Code string
Type string
Uploader string

Unlisted bool
}

const (
EmoteVisibilityPrivate int32 = 1 << iota
EmoteVisibilityGlobal
EmoteVisibilityHidden
EmoteVisibilityOverrideBTTV
EmoteVisibilityOverrideFFZ
EmoteVisibilityOverrideTwitchGlobal
EmoteVisibilityOverrideTwitchSubscriber

EmoteVisibilityAll int32 = (1 << iota) - 1
)
46 changes: 46 additions & 0 deletions internal/resolvers/seventv/resolver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package seventv

import (
"errors"
"html/template"
"regexp"
"time"

"github.com/Chatterino/api/pkg/cache"
"github.com/Chatterino/api/pkg/resolver"
)

const (
thumbnailFormat = "https://cdn.7tv.app/emote/%s/4x"
gqlQueryEmotes = `{"query": "{emote(id: \"%s\") { visibility id name owner { id display_name } }}"}`

tooltipTemplate = `<div style="text-align: left;">` +
`<b>{{.Code}}</b><br>` +
`<b>{{.Type}} SevenTV Emote</b><br>` +
`<b>By:</b> {{.Uploader}}` +
`{{ if .Unlisted }}<li><b><span style="color: red;">UNLISTED</span></b></li>{{ end }}` +
`</div>`
)

var (
seventvAPIURL = "https://api.7tv.app/v2/gql"
pajlada marked this conversation as resolved.
Show resolved Hide resolved

errInvalidSevenTVEmotePath = errors.New("invalid SevenTV emote path")

seventvEmoteURLRegex = regexp.MustCompile(`7tv.app/emotes/([a-f0-9]+)`)

emoteCache = cache.New("seventv_emotes", load, 1*time.Hour)

seventvEmoteTemplate = template.Must(template.New("seventvEmoteTooltip").Parse(tooltipTemplate))
)

func New() (resolvers []resolver.CustomURLManager) {
// Find links matching the SevenTV direct emote link (e.g. https://7tv.app/emotes/60b03e84b254a5e16b439128)
resolvers = append(resolvers, resolver.CustomURLManager{
Check: check,

Run: run,
})

return
}
19 changes: 19 additions & 0 deletions internal/resolvers/seventv/run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package seventv

import (
"encoding/json"
"net/url"
"strings"
)

func run(url *url.URL) ([]byte, error) {
matches := seventvEmoteURLRegex.FindStringSubmatch(strings.ToLower(url.Host) + url.Path)
if len(matches) != 2 {
return nil, errInvalidSevenTVEmotePath
}

emoteHash := matches[1]

apiResponse := emoteCache.Get(emoteHash, nil)
return json.Marshal(apiResponse)
}
14 changes: 14 additions & 0 deletions internal/resolvers/seventv/static_responses.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package seventv

import (
"net/http"

"github.com/Chatterino/api/pkg/resolver"
)

var (
emoteNotFoundResponse = &resolver.Response{
Status: http.StatusNotFound,
Message: "No SevenTV emote with this id found",
}
)
13 changes: 13 additions & 0 deletions pkg/resolver/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package resolver

import (
"net/http"
"strings"
"time"
)

Expand Down Expand Up @@ -43,6 +44,18 @@ func RequestGETWithHeaders(url string, extraHeaders map[string]string) (response
return httpClient.Do(req)
}

func RequestPOST(url, body string) (response *http.Response, err error) {
req, err := http.NewRequest("POST", url, strings.NewReader(body))
pajlada marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, err
}

req.Header.Set("Content-Type", "application/json")

return httpClient.Do(req)

pajlada marked this conversation as resolved.
Show resolved Hide resolved
}

func HTTPClient() *http.Client {
return httpClient
}
6 changes: 6 additions & 0 deletions pkg/utils/bit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package utils

// HasBits checks if sum contains bit by performing a bitwise AND operation between values
func HasBits(sum int32, bit int32) bool {
return (sum & bit) == bit
}