Twitch badge support implemented
This commit is contained in:
@@ -13,6 +13,7 @@ Nevertheless, the following features are already implemented:
|
|||||||
|
|
||||||
- Twitch, YouTube, Owncast and Kick chat aggregation (see "Backend implementation status" section)
|
- Twitch, YouTube, Owncast and Kick chat aggregation (see "Backend implementation status" section)
|
||||||
- Handling Twitch emotes
|
- Handling Twitch emotes
|
||||||
|
- Automated Twitch chat badges output (global and channel-specific)
|
||||||
- Handling YouTube non-Unicode emotes (see "Backend implementation status" section)
|
- Handling YouTube non-Unicode emotes (see "Backend implementation status" section)
|
||||||
- Handling Kick emotes
|
- Handling Kick emotes
|
||||||
- Message styling based on Twitch and YouTube roles
|
- Message styling based on Twitch and YouTube roles
|
||||||
@@ -27,7 +28,7 @@ Nevertheless, the following features are already implemented:
|
|||||||
## Planned features
|
## Planned features
|
||||||
|
|
||||||
- "Deleted message" support for YouTube
|
- "Deleted message" support for YouTube
|
||||||
- Automated Twitch chat badges output (global and channel-specific)
|
- Badge support for Kick
|
||||||
- Internal chat ranking system by username
|
- Internal chat ranking system by username
|
||||||
- Backend-independent support for oldschool plaintext emoticons ;-)
|
- Backend-independent support for oldschool plaintext emoticons ;-)
|
||||||
- Starting window geometry customization via config (now doable via custom JS)
|
- 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.
|
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.
|
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.
|
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.
|
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.
|
Mostly stable. Some edge cases still could be found and further investigated.
|
||||||
|
|
||||||
Most chat badges are still ignored when rendering the messages.
|
|
||||||
|
|
||||||
### YouTube (internal)
|
### 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.
|
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
|
## Credits
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
q6batx0epp608isickayubi39itsckt
|
||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"server_port": 60053,
|
"server_port": 60053,
|
||||||
"server_listen_ip": "127.0.0.1",
|
"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",
|
"useragent": "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0",
|
||||||
"tw_backend_enabled": true,
|
"tw_backend_enabled": true,
|
||||||
"tw_nickname": "Suborg",
|
"tw_nickname": "Suborg",
|
||||||
"tw_channel": "#suborg",
|
"tw_channel": "#suborg",
|
||||||
"yt_backend_enabled": true,
|
"yt_backend_enabled": false,
|
||||||
"yt_api_poll_timeout": 1,
|
"yt_api_poll_timeout": 1,
|
||||||
"yt_channel": "@foxyshadow",
|
"yt_channel": "@foxyshadow",
|
||||||
"oc_backend_enabled": false,
|
"oc_backend_enabled": false,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,8 @@ func main() {
|
|||||||
http.HandleFunc("/new", msgAddHandler)
|
http.HandleFunc("/new", msgAddHandler)
|
||||||
/* serve the GET /sse */
|
/* serve the GET /sse */
|
||||||
http.HandleFunc("/sse", sseHandler)
|
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 */
|
/* serve the webroot directory */
|
||||||
fs := http.FileServer(http.Dir(filepath.Join(APP_ROOT_DIR, "webroot")))
|
fs := http.FileServer(http.Dir(filepath.Join(APP_ROOT_DIR, "webroot")))
|
||||||
http.Handle("/", fs)
|
http.Handle("/", fs)
|
||||||
|
|||||||
+102
-4
@@ -12,12 +12,21 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
"bytes"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"unicode/utf8"
|
"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 */
|
/* helper function to write a string to the socket */
|
||||||
func tcpwritestr(conn net.Conn, str string) int {
|
func tcpwritestr(conn net.Conn, str string) int {
|
||||||
i, err := conn.Write([]byte(str))
|
i, err := conn.Write([]byte(str))
|
||||||
@@ -37,6 +46,80 @@ func tcpreadstr(conn net.Conn) string {
|
|||||||
return string(reply)
|
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 */
|
/* emote handling function */
|
||||||
func tw_handle_emotes(text string, emote_data string) string {
|
func tw_handle_emotes(text string, emote_data string) string {
|
||||||
replacements := make(map[string]string) /* future replacement string cache */
|
replacements := make(map[string]string) /* future replacement string cache */
|
||||||
@@ -112,11 +195,10 @@ func tw_process_message(rawmsg string) {
|
|||||||
if _, ok := metadata["badges"]; ok {
|
if _, ok := metadata["badges"]; ok {
|
||||||
badges := strings.Split(metadata["badges"], ",") /* they are comma-delimited */
|
badges := strings.Split(metadata["badges"], ",") /* they are comma-delimited */
|
||||||
if len(badges) > 0 { /* process badge names */
|
if len(badges) > 0 { /* process badge names */
|
||||||
for i, badge := range badges { /* extract the badge name */
|
for _, badge := range badges { /* extract the badge name and version */
|
||||||
bname, _, _ := strings.Cut(badge, "/")
|
bname, bver, _ := strings.Cut(badge, "/")
|
||||||
badges[i] = bname /* reassign */
|
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)
|
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 */
|
/* Connect to the Twitch IRC server */
|
||||||
conn, err := net.Dial("tcp", "irc.chat.twitch.tv:6667")
|
conn, err := net.Dial("tcp", "irc.chat.twitch.tv:6667")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type YTIChatOptions struct {
|
|||||||
ClientVersion string `json:"clientVersion"` /* client version string */
|
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 {
|
func yt_inner_api_req(url string, data []byte) string {
|
||||||
method := "GET"
|
method := "GET"
|
||||||
if len(data) > 0 {
|
if len(data) > 0 {
|
||||||
|
|||||||
+16
-3
@@ -43,14 +43,27 @@ function gooseAppendMessage(id, type, timestamp, username, text, icon) {
|
|||||||
var html = ""
|
var html = ""
|
||||||
if(parseInt(id) > 0 && type.length > 0) {
|
if(parseInt(id) > 0 && type.length > 0) {
|
||||||
var classes = type.split('-'), cl = classes.length, j,
|
var classes = type.split('-'), cl = classes.length, j,
|
||||||
colorpref = null
|
colorpref = null, badges = [], platform, bname
|
||||||
for(j=0;j<cl;j++) { /* detect color preference */
|
for(j=0;j<cl;j++) { /* detect color preference */
|
||||||
if(classes[j].startsWith('color'))
|
if(classes[j].startsWith('color'))
|
||||||
colorpref = classes[j].slice(5) /* remove color word */
|
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(' ') +'">'
|
html += '<div data-msg-id="' + id + '" class="' + classes.join(' ') +'">'
|
||||||
if(icon.length > 0)
|
/* handle badges and icons */
|
||||||
html += '<span class=icon><img src="' + icon + '"></span>'
|
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=timestamp>' + timestamp + '</span>'
|
||||||
html += '<span class=username'
|
html += '<span class=username'
|
||||||
if(colorpref) html += ' style="color:' + colorpref + '"'
|
if(colorpref) html += ' style="color:' + colorpref + '"'
|
||||||
|
|||||||
+3
-3
@@ -35,11 +35,11 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#msglist > div .icon img { /* userpic */
|
#msglist > div .icon img { /* userpic */
|
||||||
max-height: 1.5em;
|
max-height: 16px;
|
||||||
padding: 0 5px;
|
padding: 0 3px;
|
||||||
margin-top: -20px;
|
margin-top: -20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 4px;
|
top: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
|
|||||||
Reference in New Issue
Block a user