started Kick support
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
LDFLAGS = -s -w
|
||||
LDFLAGS_WIN = -H windowsgui -s -w
|
||||
GOFLAGS = -trimpath
|
||||
SOURCES = server.go twitch.go youtube.go owncast.go
|
||||
SOURCES = server.go twitch.go youtube.go owncast.go kick.go
|
||||
BIN = streamgoose
|
||||
|
||||
unix:
|
||||
|
||||
+11
-1
@@ -2,4 +2,14 @@ module streamgoose
|
||||
|
||||
go 1.23.1
|
||||
|
||||
require centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16
|
||||
require (
|
||||
centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16
|
||||
github.com/DaRealFreak/cloudflare-bp-go v1.0.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/EDDYCJY/fake-useragent v0.2.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.7.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f // indirect
|
||||
)
|
||||
|
||||
+27
@@ -1,2 +1,29 @@
|
||||
centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16 h1:gOFnYZ+G9R7Prc3Mn7z/v+LkPf/kQ2oUh5NCQd6n39k=
|
||||
centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16/go.mod h1:cAr/pT090G1kcy9uuCEUbZyGhUQRGOo4KVnkocx8BMA=
|
||||
github.com/DaRealFreak/cloudflare-bp-go v1.0.4 h1:33X8Z0YMV1DEVvL/kYLku+rjb4wF712+VIh3xBoifQ0=
|
||||
github.com/DaRealFreak/cloudflare-bp-go v1.0.4/go.mod h1:oBI9KAKb9FqdoB42uUqHU6pdP+YDWlKjpZRSk8JTuwk=
|
||||
github.com/EDDYCJY/fake-useragent v0.2.0 h1:Jcnkk2bgXmDpX0z+ELlUErTkoLb/mxFBNd2YdcpvJBs=
|
||||
github.com/EDDYCJY/fake-useragent v0.2.0/go.mod h1:5wn3zzlDxhKW6NYknushqinPcAqZcAPHy8lLczCdJdc=
|
||||
github.com/PuerkitoBio/goquery v1.7.1 h1:oE+T06D+1T7LNrn91B4aERsRIeCLJ/oPSa6xB9FPnz4=
|
||||
github.com/PuerkitoBio/goquery v1.7.1/go.mod h1:XY0pP4kfraEmmV1O7Uf6XyjoslwsneBbgeDjLYuN8xY=
|
||||
github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
StreamGoose Kick backend — Go reference implementation
|
||||
|
||||
Created by Luxferre in 2024, released into public domain
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"regexp"
|
||||
"time"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"io/ioutil"
|
||||
"github.com/DaRealFreak/cloudflare-bp-go"
|
||||
)
|
||||
|
||||
/* global API root URL */
|
||||
const KK_API_ROOT = "https://kick.com/api"
|
||||
|
||||
/* global debug flag */
|
||||
var KK_DEBUG bool
|
||||
|
||||
/* global useragent config */
|
||||
var KK_USERAGENT string
|
||||
|
||||
/* recent time cache */
|
||||
var KK_RECENT_TIME string
|
||||
|
||||
const KK_ID_CACHE_LIMIT = 1000
|
||||
|
||||
/* global message ID cache */
|
||||
var kk_msg_id_cache []string
|
||||
|
||||
/* API request helper function */
|
||||
func kk_api_get(endpoint string) map[string]interface{} {
|
||||
url := KK_API_ROOT + endpoint /* get full URL */
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("User-Agent", KK_USERAGENT)
|
||||
req.Header.Set("Alt-Used", "kick.com")
|
||||
req.Header.Set("Accept", "application/json; charset=utf-8")
|
||||
req.Header.Set("Accept-Language", "en-US")
|
||||
req.Header.Set("Connection", "keep-alive")
|
||||
|
||||
client := &http.Client{}
|
||||
client.Transport = cloudflarebp.AddCloudFlareByPass(client.Transport)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
body, _ := ioutil.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
/* print the response if the debug flag is set */
|
||||
if KK_DEBUG {
|
||||
log.Printf("[kick] API response: %s", string(body))
|
||||
}
|
||||
|
||||
/* unmarshal the response */
|
||||
|
||||
var respbody map[string]interface{}
|
||||
err = json.Unmarshal(body, &respbody)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return respbody
|
||||
}
|
||||
|
||||
/* emote handling function */
|
||||
/* the format is [emote:ID:NAME] */
|
||||
/* then the client will substitute this id with https://files.kick.com/emotes/ID/fullsize image */
|
||||
func kk_handle_emotes(text string) string {
|
||||
mbracket := regexp.QuoteMeta("]")
|
||||
re := regexp.MustCompile(regexp.QuoteMeta("[emote:") + "[^:]+:[^" + mbracket + "]+" + mbracket)
|
||||
text = re.ReplaceAllStringFunc(text, func(emotestr string) string {
|
||||
parts := strings.Split(emotestr, ":")
|
||||
return "#KKEMOTE-" + parts[1] + "#"
|
||||
})
|
||||
return text
|
||||
}
|
||||
|
||||
/* main message procesing function */
|
||||
func kk_process_message(rawmsg map[string]interface{}) {
|
||||
/* if the message came from a real user, this is the one we should process */
|
||||
if rawmsg["type"] == "message" {
|
||||
if !slices.Contains(kk_msg_id_cache, rawmsg["id"].(string)) { /* we have a fresh message here */
|
||||
var author map[string]interface{}
|
||||
/* check if the "sender" field is present */
|
||||
if _, ok := rawmsg["sender"]; ok {
|
||||
author = rawmsg["sender"].(map[string]interface{})
|
||||
} else {
|
||||
author["username"] = "System"
|
||||
}
|
||||
|
||||
KK_RECENT_TIME = rawmsg["created_at"].(string)
|
||||
|
||||
/* convert the timestamp */
|
||||
sent_dt, _ := time.Parse("2006-01-02T15:04:05Z", KK_RECENT_TIME)
|
||||
|
||||
/* update the recent time variable */
|
||||
KK_RECENT_TIME = fmt.Sprintf("%d", sent_dt.Unix())
|
||||
|
||||
/* prepare output message */
|
||||
out_message := ChatMessage {
|
||||
Type: "kk",
|
||||
Username: author["username"].(string),
|
||||
Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
Text: rawmsg["content"].(string),
|
||||
Icon: "",
|
||||
}
|
||||
|
||||
/* get user metadata */
|
||||
metadata := author["identity"].(map[string]interface{})
|
||||
|
||||
/* process username color preference */
|
||||
if _, ok := metadata["color"]; ok && len(metadata["color"].(string)) > 0 {
|
||||
out_message.Type += "-color" + metadata["color"].(string)
|
||||
}
|
||||
|
||||
/* process username badges */
|
||||
if _, ok := metadata["badges"]; ok && len(metadata["badges"].([]interface{})) > 0 {
|
||||
for _, badge := range metadata["badges"].([]interface{}) {
|
||||
if badge.(map[string]interface{})["active"].(bool) {
|
||||
out_message.Type += "-" + badge.(map[string]interface{})["text"].(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* process emotes */
|
||||
out_message.Text = kk_handle_emotes(out_message.Text)
|
||||
|
||||
/* finally, send it to the message broker via the message_transport channel */
|
||||
message_transport <- out_message
|
||||
|
||||
/* update the ID cache */
|
||||
kk_msg_id_cache = append(kk_msg_id_cache, rawmsg["id"].(string))
|
||||
cachelen := len(kk_msg_id_cache)
|
||||
if cachelen > KK_ID_CACHE_LIMIT { /* trim the excess */
|
||||
kk_msg_id_cache = kk_msg_id_cache[cachelen-KK_ID_CACHE_LIMIT:cachelen]
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* entry point */
|
||||
func kick_run(config map[string]interface{}) {
|
||||
if _, ok := config["debug"]; ok {
|
||||
KK_DEBUG = config["debug"].(bool)
|
||||
}
|
||||
|
||||
if _, ok := config["useragent"]; ok {
|
||||
KK_USERAGENT = config["useragent"].(string)
|
||||
}
|
||||
|
||||
API_POLL_TIMEOUT := 2
|
||||
|
||||
KK_RECENT_TIME = "" /* init the recent time */
|
||||
|
||||
if _, ok := config["kk_api_poll_timeout"]; ok {
|
||||
API_POLL_TIMEOUT = int(config["kk_api_poll_timeout"].(float64))
|
||||
}
|
||||
|
||||
/* get chat ID by channel name */
|
||||
channel_id_endpoint := fmt.Sprintf("/v2/channels/%s", config["kk_channel"].(string))
|
||||
if KK_DEBUG {
|
||||
log.Printf("[kick] querying %s", channel_id_endpoint)
|
||||
}
|
||||
channel_id_data := kk_api_get(channel_id_endpoint)
|
||||
|
||||
channel_id := 0
|
||||
if _, ok := channel_id_data["id"]; ok && channel_id_data["id"].(float64) > 0 {
|
||||
channel_id = int(channel_id_data["id"].(float64))
|
||||
}
|
||||
|
||||
if channel_id < 1 { /* the channel ID is still empty */
|
||||
log.Fatal("[kick] Unknown error while getting channel ID!")
|
||||
}
|
||||
|
||||
log.Println("StreamGoose Kick Go backend started")
|
||||
|
||||
var raw_chat_data map[string]interface{}
|
||||
kk_msg_id_cache = append(kk_msg_id_cache, "") /* init the cache */
|
||||
for { /* main loop */
|
||||
chat_api_endpoint := fmt.Sprintf("/v2/channels/%d/messages?start_time=%s", channel_id, KK_RECENT_TIME)
|
||||
raw_chat_data = kk_api_get(chat_api_endpoint)
|
||||
if raw_chat_data["status"].(map[string]interface{})["error"].(bool) == false {
|
||||
msg_arr := raw_chat_data["data"].(map[string]interface{})["messages"].([]interface{})
|
||||
for _, rawmsg := range msg_arr { /* iterate over raw message objects */
|
||||
kk_process_message(rawmsg.(map[string]interface{}))
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Duration(API_POLL_TIMEOUT) * time.Second) /* wait for specified amount before polling next */
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -126,7 +126,7 @@ func oc_process_message(rawmsg map[string]interface{}) {
|
||||
func owncast_run(config map[string]interface{}) {
|
||||
token_path := "auth/oc-token.txt"
|
||||
token, err := ioutil.ReadFile(token_path)
|
||||
token_err_msg := "No Owncast token file provided or it is empty. "
|
||||
token_err_msg := "[owncast] No Owncast token file provided or it is empty. "
|
||||
token_err_msg += "Please generate a token in your Owncast admin panel "
|
||||
token_err_msg += "and paste it into the auth/oc-token.txt file!"
|
||||
if err != nil {
|
||||
@@ -146,7 +146,7 @@ func owncast_run(config map[string]interface{}) {
|
||||
if _, ok := config["oc_server"]; ok {
|
||||
OC_API_ROOT = config["oc_server"].(string)
|
||||
} else {
|
||||
log.Fatal("Owncast server missing in config!")
|
||||
log.Fatal("[owncast] Owncast server missing in config!")
|
||||
}
|
||||
|
||||
if _, ok := config["oc_api_poll_timeout"]; ok {
|
||||
|
||||
@@ -168,6 +168,10 @@ func main() {
|
||||
go owncast_run(config) /* start the Owncast backend */
|
||||
}
|
||||
|
||||
if _, ok := config["kk_backend_enabled"]; ok && config["kk_backend_enabled"].(bool) == true {
|
||||
go kick_run(config) /* start the Kick backend */
|
||||
}
|
||||
|
||||
/* start the GUI */
|
||||
debug := false
|
||||
if _, ok := config["debug"]; ok && config["debug"].(bool) == true {
|
||||
|
||||
+3
-3
@@ -55,7 +55,7 @@ func tw_handle_emotes(text string, emote_data string) string {
|
||||
eend_int = textlen - 1
|
||||
}
|
||||
/* use rune conversion for correct offsets */
|
||||
replacements[string([]rune(text)[estart_int:eend_int + 1])] = "#EMOTE-" + emote_id + "#"
|
||||
replacements[string([]rune(text)[estart_int:eend_int + 1])] = "#TWEMOTE-" + emote_id + "#"
|
||||
}
|
||||
}
|
||||
for from, to := range replacements { /* iterate over replacements */
|
||||
@@ -144,7 +144,7 @@ func tw_process_message(rawmsg string) {
|
||||
func twitch_run(config map[string]interface{}) {
|
||||
token_path := "auth/twitch-token.txt"
|
||||
token, err := ioutil.ReadFile(token_path)
|
||||
token_err_msg := "No Twitch token file provided or it is empty. "
|
||||
token_err_msg := "[twitch] No Twitch token file provided or it is empty. "
|
||||
token_err_msg += "Please visit https://twitchapps.com/tmi/, generate a token there "
|
||||
token_err_msg += "and paste it into the auth/twitch-token.txt file!"
|
||||
if err != nil {
|
||||
@@ -158,7 +158,7 @@ func twitch_run(config map[string]interface{}) {
|
||||
/* Connect to the Twitch IRC server */
|
||||
conn, err := net.Dial("tcp", "irc.chat.twitch.tv:6667")
|
||||
if err != nil {
|
||||
log.Fatal("Error connecting to Twitch: ", err)
|
||||
log.Fatal("[twitch] Error connecting to Twitch: ", err)
|
||||
}
|
||||
|
||||
tcpwritestr(conn, fmt.Sprintf("PASS %s\n", config["tw_token"]))
|
||||
|
||||
+5
-5
@@ -114,7 +114,7 @@ func youtube_run(config map[string]interface{}) {
|
||||
if _, ok := config["yt_api_root_url"]; ok {
|
||||
YT_API_ROOT = config["yt_api_root_url"].(string)
|
||||
} else {
|
||||
log.Fatal("API Root URL missing in config!")
|
||||
log.Fatal("[youtube] API Root URL missing in config!")
|
||||
}
|
||||
|
||||
if _, ok := config["yt_api_poll_timeout"]; ok {
|
||||
@@ -131,7 +131,7 @@ func youtube_run(config map[string]interface{}) {
|
||||
if _, ok := channel_id_data["items"]; ok && len(channel_id_data["items"].([]interface{})) > 0 {
|
||||
channel_id = channel_id_data["items"].([]interface{})[0].(map[string]interface{})["id"].(string)
|
||||
} else {
|
||||
log.Fatal("Error while getting channel ID!")
|
||||
log.Fatal("[youtube] Error while getting channel ID!")
|
||||
}
|
||||
|
||||
/* then, get live broadcast video ID by channel ID */
|
||||
@@ -140,7 +140,7 @@ func youtube_run(config map[string]interface{}) {
|
||||
id_desc := video_id_data["items"].([]interface{})[0].(map[string]interface{})["id"]
|
||||
video_id = id_desc.(map[string]interface{})["videoId"].(string)
|
||||
} else {
|
||||
log.Fatal("Error while getting video ID!")
|
||||
log.Fatal("[youtube] Error while getting video ID!")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,11 +151,11 @@ func youtube_run(config map[string]interface{}) {
|
||||
details := chat_id_data["items"].([]interface{})[0].(map[string]interface{})["liveStreamingDetails"]
|
||||
chat_id = details.(map[string]interface{})["activeLiveChatId"].(string)
|
||||
} else {
|
||||
log.Fatal("Error while getting live chat ID!")
|
||||
log.Fatal("[youtube] Error while getting live chat ID!")
|
||||
}
|
||||
|
||||
if len(chat_id) < 1 { /* the chat ID is still empty */
|
||||
log.Fatal("Unknown error while getting live chat ID!")
|
||||
log.Fatal("[youtube] Unknown error while getting live chat ID!")
|
||||
}
|
||||
|
||||
log.Println("StreamGoose YouTube Go backend started")
|
||||
|
||||
Reference in New Issue
Block a user