Twitch badge support implemented

This commit is contained in:
Luxferre
2024-09-20 12:21:28 +03:00
parent 4e972bd5f5
commit 56f7da4748
9 changed files with 180 additions and 16 deletions
+8 -3
View File
@@ -13,6 +13,7 @@ Nevertheless, the following features are already implemented:
- Twitch, YouTube, Owncast and Kick chat aggregation (see "Backend implementation status" section)
- Handling Twitch emotes
- Automated Twitch chat badges output (global and channel-specific)
- Handling YouTube non-Unicode emotes (see "Backend implementation status" section)
- Handling Kick emotes
- Message styling based on Twitch and YouTube roles
@@ -27,7 +28,7 @@ Nevertheless, the following features are already implemented:
## Planned features
- "Deleted message" support for YouTube
- Automated Twitch chat badges output (global and channel-specific)
- Badge support for Kick
- Internal chat ranking system by username
- Backend-independent support for oldschool plaintext emoticons ;-)
- Starting window geometry customization via config (now doable via custom JS)
@@ -191,7 +192,7 @@ The following procedure needs to be only done once per ~2 months:
1. Go to the [Twitch Chat OAuth Password Generator](https://twitchapps.com/tmi/) website.
2. Login with your own Twitch account and give it the access.
3. Copy the token string that starts with `oauth:` and paste it into the `twitch-token.txt` file in the `auth` subdirectory in the application directory.
3. Copy the token string that starts with `oauth:` and paste it into the `twitch-token.txt` file in the `auth` subdirectory in the application directory (note that it is different from `twitch-cid.txt` that already is present in this subdirectory).
4. Save the file. The Twitch connection should now work.
5. If the connection doesn't work, make sure that the `tw_backend_enabled` field is set to `true` in `config.json` and that you used the same Twitch nickname to get the token as the one specified in the `tw_nickname` field.
@@ -273,7 +274,6 @@ Each regular expression captures: the string start or a whitespace character, th
Mostly stable. Some edge cases still could be found and further investigated.
Most chat badges are still ignored when rendering the messages.
### YouTube (internal)
@@ -319,6 +319,11 @@ Sure, anything can happen. The problem with adding a new platform support, as al
Besides the main four, anyway, your best bet would be to create an **external** backend for the platform you're interested in (again, you could use any programming language you want), and then incorporate it into this repo in the `/external` directory. When the backend is mature enough, we can always sit and think about how to convert it into an internal one to be distributed inside the StreamGoose binary.
### Why is there a `twitch-cid.txt` file in the `auth/` directory?
This is the Client ID corresponding to the [Twitch Chat OAuth Password Generator](https://twitchapps.com/tmi/) website. Id you generate a Twitch token through it, you'll need this ID for external Twitch API such as badges to work correctly.
Twitch client IDs are not considered secrets and can be included in the source code.
## Credits
+1
View File
@@ -0,0 +1 @@
q6batx0epp608isickayubi39itsckt
+2 -2
View File
@@ -1,12 +1,12 @@
{
"server_port": 60053,
"server_listen_ip": "127.0.0.1",
"debug": false,
"debug": true,
"useragent": "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0",
"tw_backend_enabled": true,
"tw_nickname": "Suborg",
"tw_channel": "#suborg",
"yt_backend_enabled": true,
"yt_backend_enabled": false,
"yt_api_poll_timeout": 1,
"yt_channel": "@foxyshadow",
"oc_backend_enabled": false,
+45
View File
@@ -0,0 +1,45 @@
/*
StreamGoose internal badge provider — Go reference implementation
Created by Luxferre in 2024, released into public domain
*/
package main
import (
"net/http"
)
/* platform-specific badge provider implementations */
func badge_provider_impls() map[string]interface{} {
return map[string]interface{} {
"tw": func(badgename string) string { /* Twitch implementation */
if _, ok := TW_BADGE_CACHE[badgename]; ok { /* found */
return TW_BADGE_CACHE[badgename]
} else { /* not found */
return ""
}
},
/* "kk": func(badgename string) {}, */
}
}
/* serve the GET /badge/{platform}/{badgename} request */
/* returns a 301-type redirect to the resolved URL or 404 if unresolved */
func bp_handle_request(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
badgename := r.PathValue("badgename")
impls := badge_provider_impls()
if _, ok := impls[platform]; ok { /* process the badge and return 301 */
badgeurl := impls[platform].(func(string) string)(badgename)
if len(badgeurl) > 0 { /* return 301 redirect */
http.Redirect(w, r, badgeurl, http.StatusMovedPermanently)
} else { /* return 404 */
http.Error(w, "404 - Badge not found", http.StatusNotFound)
w.Write([]byte("404 - Badge not found"))
}
} else { /* return 404 */
http.Error(w, "404 - Badge provider not found", http.StatusNotFound)
}
}
+2
View File
@@ -169,6 +169,8 @@ func main() {
http.HandleFunc("/new", msgAddHandler)
/* serve the GET /sse */
http.HandleFunc("/sse", sseHandler)
/* serve the GET /badge/{platform}/{badgename} (call to the badge provider) */
http.HandleFunc("/badge/{platform}/{badgename}", bp_handle_request)
/* serve the webroot directory */
fs := http.FileServer(http.Dir(filepath.Join(APP_ROOT_DIR, "webroot")))
http.Handle("/", fs)
+102 -4
View File
@@ -12,12 +12,21 @@ import (
"strings"
"strconv"
"time"
"bytes"
"net"
"net/http"
"io/ioutil"
"path/filepath"
"unicode/utf8"
"encoding/json"
)
/* Twitch badge URL cache */
var TW_BADGE_CACHE map[string]string
/* Twitch OAuth global cache */
var TW_OAUTH_TOKEN string
var TW_CLIENT_ID string
/* helper function to write a string to the socket */
func tcpwritestr(conn net.Conn, str string) int {
i, err := conn.Write([]byte(str))
@@ -37,6 +46,80 @@ func tcpreadstr(conn net.Conn) string {
return string(reply)
}
/* non-IRC API request helper function */
func tw_api_req(url string, data string) []byte {
method := "GET"
if len(data) > 0 {
method = "POST"
}
req, err := http.NewRequest(method, "https://api.twitch.tv/helix" + url, bytes.NewReader([]byte(data)))
req.Header.Set("Authorization", "Bearer " + TW_OAUTH_TOKEN)
req.Header.Set("Client-Id", TW_CLIENT_ID)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
body, _ := ioutil.ReadAll(resp.Body)
resp.Body.Close()
return body
}
/* badge cache population function */
func tw_fetch_badge_cache(channelname string) map[string]string {
readycache := make(map[string]string)
broadcaster_id := ""
var ustruct map[string]interface{}
/* get broadcaster ID from the channel name */
body := tw_api_req(fmt.Sprintf("/users?login=%s", channelname), "")
if len(body) > 0 {
err := json.Unmarshal(body, &ustruct)
if err == nil {
if _, ok := ustruct["data"]; ok && len(ustruct["data"].([]interface{})) > 0 {
bdata := ustruct["data"].([]interface{})[0].(map[string]interface{})
broadcaster_id = bdata["id"].(string)
}
}
}
/* get the global badges */
body = tw_api_req("/chat/badges/global", "")
if len(body) > 0 {
err := json.Unmarshal(body, &ustruct)
if err == nil {
if _, ok := ustruct["data"]; ok && len(ustruct["data"].([]interface{})) > 0 {
for _, bdata := range ustruct["data"].([]interface{}) { /* fetch individual badges */
bsetid := bdata.(map[string]interface{})["set_id"].(string)
for _, bver := range bdata.(map[string]interface{})["versions"].([]interface{}) {
bid := bver.(map[string]interface{})["id"].(string) /* badge ID */
burl := bver.(map[string]interface{})["image_url_1x"].(string) /* badge primary URL */
readycache[bsetid + "_" + bid] = burl
}
}
}
}
}
if broadcaster_id != "" { /* also get the channel-specific badges */
body = tw_api_req(fmt.Sprintf("/chat/badges?broadcaster_id=%s", broadcaster_id), "")
if len(body) > 0 {
err := json.Unmarshal(body, &ustruct)
if err == nil {
if _, ok := ustruct["data"]; ok && len(ustruct["data"].([]interface{})) > 0 {
for _, bdata := range ustruct["data"].([]interface{}) { /* fetch individual badges */
bsetid := bdata.(map[string]interface{})["set_id"].(string)
for _, bver := range bdata.(map[string]interface{})["versions"].([]interface{}) {
bid := bver.(map[string]interface{})["id"].(string) /* badge ID */
burl := bver.(map[string]interface{})["image_url_1x"].(string) /* badge primary URL */
readycache[bsetid + "_" + bid] = burl
}
}
}
}
}
}
return readycache
}
/* emote handling function */
func tw_handle_emotes(text string, emote_data string) string {
replacements := make(map[string]string) /* future replacement string cache */
@@ -112,11 +195,10 @@ func tw_process_message(rawmsg string) {
if _, ok := metadata["badges"]; ok {
badges := strings.Split(metadata["badges"], ",") /* they are comma-delimited */
if len(badges) > 0 { /* process badge names */
for i, badge := range badges { /* extract the badge name */
bname, _, _ := strings.Cut(badge, "/")
badges[i] = bname /* reassign */
for _, badge := range badges { /* extract the badge name and version */
bname, bver, _ := strings.Cut(badge, "/")
out_message.Type += "-" + bname + "-badgetw" + bname + "_" + bver
}
out_message.Type += "-" + strings.Join(badges, "-")
}
}
@@ -161,6 +243,22 @@ func twitch_run(config map[string]interface{}) {
log.Fatal(token_err_msg)
}
cid_path := filepath.Join(APP_ROOT_DIR, "auth", "twitch-cid.txt")
cid, err := ioutil.ReadFile(cid_path)
cid_err_msg := "[twitch] No Twitch client file provided or it is empty. "
cid_err_msg += "Please visit https://twitchapps.com/tmi/, find a client ID in the source "
cid_err_msg += "and paste it into the " + cid_path +" file!"
if err != nil {
log.Fatal(cid_err_msg + "\nDetailed error: ", err)
}
/* populate TW_OAUTH_TOKEN and TW_CLIENT_ID globals for non-IRC API calls */
TW_CLIENT_ID = strings.TrimSpace(string(cid))
TW_OAUTH_TOKEN, _ = strings.CutPrefix(config["tw_token"].(string), "oauth:")
/* load the Twitch badge cache */
TW_BADGE_CACHE = tw_fetch_badge_cache(config["tw_channel"].(string))
/* Connect to the Twitch IRC server */
conn, err := net.Dial("tcp", "irc.chat.twitch.tv:6667")
if err != nil {
+1 -1
View File
@@ -29,7 +29,7 @@ type YTIChatOptions struct {
ClientVersion string `json:"clientVersion"` /* client version string */
}
/* API request helper functions */
/* API request helper function */
func yt_inner_api_req(url string, data []byte) string {
method := "GET"
if len(data) > 0 {
+16 -3
View File
@@ -43,14 +43,27 @@ function gooseAppendMessage(id, type, timestamp, username, text, icon) {
var html = ""
if(parseInt(id) > 0 && type.length > 0) {
var classes = type.split('-'), cl = classes.length, j,
colorpref = null
colorpref = null, badges = [], platform, bname
for(j=0;j<cl;j++) { /* detect color preference */
if(classes[j].startsWith('color'))
colorpref = classes[j].slice(5) /* remove color word */
if(classes[j].startsWith('badge')) { /* remove the class but cache it */
badges.push(classes[j].slice(5)) /* remove badge word */
classes[j] = "" /* remove the class from the list */
}
}
html += '<div data-msg-id="' + id + '" class="' + classes.join(' ') +'">'
if(icon.length > 0)
html += '<span class=icon><img src="' + icon + '"></span>'
/* handle badges and icons */
html += '<span class=icon>'
for(j=0,cl=badges.length;j<cl;j++) { /* iterate over badges */
platform = badges[j].slice(0,2) /* first 2 characters denote the platform */
bname = badges[j].slice(2) /* thre rest is the badge name itself */
html += '<img class=badge src="/badge/{platform}/{badgename}">'
.replace('{platform}', platform).replace('{badgename}', bname)
}
if(icon.length > 0) /* user icon, if present */
html += '<img src="' + icon + '">'
html += '</span>'
html += '<span class=timestamp>' + timestamp + '</span>'
html += '<span class=username'
if(colorpref) html += ' style="color:' + colorpref + '"'
+3 -3
View File
@@ -35,11 +35,11 @@ html, body {
}
#msglist > div .icon img { /* userpic */
max-height: 1.5em;
padding: 0 5px;
max-height: 16px;
padding: 0 3px;
margin-top: -20px;
position: relative;
top: 4px;
top: 3px;
}
.username {