diff --git a/README.md b/README.md index 685bc0f..6706c60 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/auth/twitch-cid.txt b/auth/twitch-cid.txt new file mode 100644 index 0000000..8a9ff7b --- /dev/null +++ b/auth/twitch-cid.txt @@ -0,0 +1 @@ +q6batx0epp608isickayubi39itsckt diff --git a/config.json b/config.json index ecd1cb0..ae661db 100644 --- a/config.json +++ b/config.json @@ -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, diff --git a/src/badge-provider.go b/src/badge-provider.go new file mode 100644 index 0000000..fe77940 --- /dev/null +++ b/src/badge-provider.go @@ -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) + } +} diff --git a/src/server.go b/src/server.go index d4196a2..6ba29e2 100644 --- a/src/server.go +++ b/src/server.go @@ -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) diff --git a/src/twitch.go b/src/twitch.go index 295c469..42105cc 100644 --- a/src/twitch.go +++ b/src/twitch.go @@ -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 { diff --git a/src/youtube-innertube.go b/src/youtube-innertube.go index c5caaf2..1784769 100644 --- a/src/youtube-innertube.go +++ b/src/youtube-innertube.go @@ -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 { diff --git a/webroot/script.js b/webroot/script.js index 1e9f907..724e87e 100644 --- a/webroot/script.js +++ b/webroot/script.js @@ -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' - if(icon.length > 0) - html += '' + /* handle badges and icons */ + html += '' + for(j=0,cl=badges.length;j 0) /* user icon, if present */ + html += '' + html += '' html += '' + timestamp + '' html += ' 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 {