initial upload

This commit is contained in:
Luxferre
2024-09-14 14:41:59 +03:00
commit 9f04abf49c
118 changed files with 1080 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
LDFLAGS = -s -w
GOFLAGS = -trimpath
SOURCES = server.go twitch.go youtube.go
BIN = streamgoose
all: $(SOURCES)
go mod tidy
go build -o $(BIN) $(GOFLAGS) -ldflags "$(LDFLAGS)" $(SOURCES)
clean:
rm $(BIN)
+5
View File
@@ -0,0 +1,5 @@
module streamgoose
go 1.23.1
require centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16
+2
View File
@@ -0,0 +1,2 @@
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=
+132
View File
@@ -0,0 +1,132 @@
/*
StreamGoose main server/UI launcher — Go reference implementation
Created by Luxferre in 2024, released into public domain
*/
package main
import (
"os"
"fmt"
"log"
"strings"
"encoding/json"
"net/http"
"io/ioutil"
/* external */
"centrifuge.hectabit.org/HectaBit/webview_go" /* for UI launch */
)
/* main chat message structure */
type ChatMessage struct {
Id uint64 `json:"id"` /* message id (auto-filled) */
Type string `json:"type"` /* message type string */
Timestamp string `json:"timestamp"` /* timestamp string (formatted) */
Username string `json:"username"` /* username */
Text string `json:"text"` /* message text */
Icon string `json:"icon"` /* optional message icon URI */
}
var global_msg_id uint64 = 0 /* track global message ID */
var message_transport chan ChatMessage /* global message channel */
var bro webview.WebView /* global browser instance handle */
/* escape HTML entities in JS parameters */
func esc(s string) string {
s = strings.ReplaceAll(s, "&", "&")
s = strings.ReplaceAll(s, "'", "'")
s = strings.ReplaceAll(s, "\"", """)
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
/* chat message appender thread */
func appendChatMessages() {
var msg ChatMessage
for {
msg = <-message_transport /* receive it from the channel */
/* populate the running id */
global_msg_id += 1
msg.Id = global_msg_id
/* directly call the append function on the JS side */
fmt_str := "gooseAppendMessage('%d', '%s', '%s', '%s', '%s', '%s')"
bro.Eval(fmt.Sprintf(fmt_str, msg.Id, esc(msg.Type), esc(msg.Timestamp), esc(msg.Username), esc(msg.Text), esc(msg.Icon)))
}
}
/* POST /new handler (for external backends) */
func msgAddHandler(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var msg ChatMessage /* create a new message */
err := decoder.Decode(&msg) /* fill in the message structure */
if err != nil {
log.Println("Error decoding POST request:", err)
return
}
/* post it to the transport channel */
message_transport <- msg
w.Write([]byte("{\"status\": 1}")) /* respond with a status */
}
/* entry point */
func main() {
config_path := "config.json"
if len(os.Args) > 1 {
config_path = os.Args[1]
}
content, err := ioutil.ReadFile(config_path)
if err != nil {
log.Fatal("Error when opening config file: ", err)
}
var config map[string]interface{}
err = json.Unmarshal(content, &config)
if err != nil {
log.Fatal("Error during Unmarshal(): ", err)
}
/* create the message transport channel */
message_transport = make(chan ChatMessage)
/* target server listening address */
target_addr := fmt.Sprintf("%s:%d", config["server_listen_ip"].(string), int(config["server_port"].(float64)))
go appendChatMessages() /* start the appender thread */
go func() { /* start the server thread */
/* serve the POST /new */
http.HandleFunc("/new", msgAddHandler)
/* serve the webroot directory */
fs := http.FileServer(http.Dir("./webroot"))
http.Handle("/", fs)
log.Println(fmt.Sprintf("Listening on %s\n", target_addr))
err = http.ListenAndServe(target_addr, nil)
if err != nil {
log.Fatal(err)
}
}()
if _, ok := config["tw_backend_enabled"]; ok && config["tw_backend_enabled"].(bool) == true {
go twitch_run(config) /* start the Twitch backend */
}
if _, ok := config["yt_backend_enabled"]; ok && config["yt_backend_enabled"].(bool) == true {
go youtube_run(config) /* start the YouTube backend */
}
/* start the GUI */
debug := false
if _, ok := config["debug"]; ok && config["debug"].(bool) == true {
debug = true
}
bro = webview.New(debug) /* create a browser instance */
bro.SetSize(320, 720, webview.HintNone)
defer bro.Destroy()
bro.SetTitle("StreamGoose v0.0.1")
bro.Navigate("http://" + target_addr)
bro.Run()
}
+185
View File
@@ -0,0 +1,185 @@
/*
StreamGoose Twitch backend — Go reference implementation
Created by Luxferre in 2024, released into public domain
*/
package main
import (
"fmt"
"log"
"strings"
"strconv"
"time"
"net"
"io/ioutil"
"unicode/utf8"
)
/* helper function to write a string to the socket */
func tcpwritestr(conn net.Conn, str string) int {
i, err := conn.Write([]byte(str))
if err != nil {
log.Fatal("Error writing to socket: ", err)
}
return i
}
/* helper function to read a string from the socket */
func tcpreadstr(conn net.Conn) string {
reply := make([]byte, 2048)
_, err := conn.Read(reply)
if err != nil {
log.Fatal("Error reading from socket: ", err)
}
return string(reply)
}
/* emote handling function */
func tw_handle_emotes(text string, emote_data string) string {
replacements := make(map[string]string) /* future replacement string cache */
parts := strings.Split(emote_data, "/")
textlen := utf8.RuneCountInString(text)
for _, emotedesc := range parts { /* iterate over emote descriptors */
emote_id, emote_range, _ := strings.Cut(emotedesc, ":")
emote_ranges := strings.Split(emote_range, ",") /* there can be several ranges */
for _, r := range emote_ranges {
estart, eend, _ := strings.Cut(r, "-")
estart_int, _ := strconv.Atoi(estart)
eend_int, _ := strconv.Atoi(eend)
if estart_int < 0 {
estart_int = 0
}
if eend_int >= textlen {
eend_int = textlen - 1
}
/* use rune conversion for correct offsets */
replacements[string([]rune(text)[estart_int:eend_int + 1])] = "#EMOTE-" + emote_id + "#"
}
}
for from, to := range replacements { /* iterate over replacements */
text = strings.ReplaceAll(text, from, to)
}
return text
}
/* main message procesing function */
func tw_process_message(rawmsg string) {
/* if the message came from a real user, this is the one we should process */
if strings.HasPrefix(rawmsg, "@badge-info=") {
parts := strings.Split(rawmsg, " :") /* get raw message parts */
/* first part is metadata, second is a full IRC username,
the rest is the message ending with \r\n */
metadata_str := strings.TrimSpace(parts[0])
msg_text := strings.Join(parts[2:], " :")
msg_text = strings.ReplaceAll(msg_text, "\u0000", "") /* remove excess null bytes */
metadata := make(map[string]string) /* store parsed metadata here */
parts = strings.Split(metadata_str, ";")
for _, field := range parts { /* iterate over metadata fields */
fieldkey, fieldval, _ := strings.Cut(field, "=")
metadata[fieldkey] = fieldval
}
/* convert the timestamp */
ts, err := strconv.ParseInt(metadata["tmi-sent-ts"], 10, 0)
if err != nil { /* fallback to current time if anything */
ts = time.Now().UnixMilli()
}
sent_dt := time.UnixMilli(ts)
/* process Twitch emotes before any positional changes can occur */
if _, ok := metadata["emotes"]; ok && len(metadata["emotes"]) > 0 {
msg_text = tw_handle_emotes(msg_text, metadata["emotes"])
}
/* sanitize \x01ACTION... \x01 in the text*/
msg_text = strings.ReplaceAll(msg_text, "\u0001ACTION", "/me")
msg_text = strings.ReplaceAll(msg_text, "\u0001", "")
/* prepare output message */
out_message := ChatMessage {
Type: "tw",
Username: metadata["display-name"],
Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
Text: strings.TrimSpace(msg_text),
Icon: "",
}
/* process username badges */
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 */
}
out_message.Type += "-" + strings.Join(badges, "-")
}
}
/* process username color preference */
if _, ok := metadata["color"]; ok && len(metadata["color"]) > 0 {
out_message.Type += "-color" + metadata["color"]
}
/* process third-party action */
if strings.HasPrefix(out_message.Text, "/me") {
out_message.Type += "-me"
out_message.Text = strings.TrimSpace(out_message.Text[4:])
}
/* process system messages (e.g. in USERNOTICE event) */
if _, ok := metadata["system-msg"]; ok && len(metadata["system-msg"]) > 0 {
out_message.Text = strings.Join(strings.Fields(metadata["system-msg"]), " ") + ": " + out_message.Text
}
/* finally, send it to the message broker via the message_transport channel */
message_transport <- out_message
}
}
/* entry point */
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 += "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 {
log.Fatal(token_err_msg + "\nDetailed error: ", err)
}
config["tw_token"] = strings.TrimSpace(string(token))
if len(config["tw_token"].(string)) < 1 {
log.Fatal(token_err_msg)
}
/* 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)
}
tcpwritestr(conn, fmt.Sprintf("PASS %s\n", config["tw_token"]))
tcpwritestr(conn, fmt.Sprintf("NICK %s\n", config["tw_nickname"]))
tcpwritestr(conn, fmt.Sprintf("JOIN %s\n", config["tw_channel"]))
/* request advanced chat message capabilities */
tcpwritestr(conn, "CAP REQ :twitch.tv/membership twitch.tv/tags twitch.tv/commands\n")
log.Println("StreamGoose Twitch Go backend connected")
var rawmsg string
for { /* main loop */
rawmsg = tcpreadstr(conn)
if _, ok := config["debug"]; ok && config["debug"].(bool) == true {
log.Println(rawmsg)
}
if strings.HasPrefix(rawmsg, "PING") { /* respond to the PING message */
tcpwritestr(conn, "PONG\n")
} else { /* run main procesing */
tw_process_message(rawmsg)
}
}
}
+174
View File
@@ -0,0 +1,174 @@
/*
StreamGoose YouTube backend — Go reference implementation
Created by Luxferre in 2024, released into public domain
*/
package main
import (
"log"
"slices"
"time"
"encoding/json"
"net/http"
"io/ioutil"
)
/* global API root URL placeholder */
var API_ROOT string
const ID_CACHE_LIMIT = 1000
/* global message ID cache */
var msg_id_cache []string
/* API request helper function */
func api_get(endpoint string) map[string]interface{} {
url := API_ROOT + endpoint /* get full URL */
req, err := http.NewRequest("GET", url, nil)
req.Header.Set("Accept", "application/json; charset=utf-8")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
body, _ := ioutil.ReadAll(resp.Body)
resp.Body.Close()
/* unmarshal the response */
var respbody map[string]interface{}
err = json.Unmarshal(body, &respbody)
if err != nil {
log.Fatal(err)
}
return respbody
}
/* main message procesing function */
func yt_process_message(rawmsg map[string]interface{}) {
/* if the message came from a real user, this is the one we should process */
if rawmsg["kind"] == "youtube#liveChatMessage" && rawmsg["snippet"].(map[string]interface{})["type"] == "textMessageEvent" {
if !slices.Contains(msg_id_cache, rawmsg["id"].(string)) { /* we have a fresh message here */
snip := rawmsg["snippet"].(map[string]interface{})
author := rawmsg["authorDetails"].(map[string]interface{})
/* convert the timestamp */
sent_dt, _ := time.Parse("2006-01-02T15:04:05.000000-07:00", snip["publishedAt"].(string))
/* prepare output message */
out_message := ChatMessage {
Type: "yt",
Username: author["displayName"].(string),
Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
Text: snip["displayMessage"].(string),
Icon: "",
}
/* check for profile image */
if _, ok := author["profileImageUrl"]; ok && len(author["profileImageUrl"].(string)) > 0 {
out_message.Icon = author["profileImageUrl"].(string)
}
/* check for verified role */
if _, ok := author["isVerified"]; ok && author["isVerified"].(bool) == true {
out_message.Type += "-verified"
}
/* check for broadcaster role */
if _, ok := author["isChatOwner"]; ok && author["isChatOwner"].(bool) == true {
out_message.Type += "-broadcaster"
}
/* check for moderator role */
if _, ok := author["isChatModerator"]; ok && author["isChatModerator"].(bool) == true {
out_message.Type += "-moderator"
}
/* check for sponsor role */
if _, ok := author["isChatSponsor"]; ok && author["isChatSponsor"].(bool) == true {
out_message.Type += "-sponsor"
}
/* finally, send it to the message broker via the message_transport channel */
message_transport <- out_message
/* update the ID cache */
msg_id_cache = append(msg_id_cache, rawmsg["id"].(string))
cachelen := len(msg_id_cache)
if cachelen > ID_CACHE_LIMIT { /* trim the excess */
msg_id_cache = msg_id_cache[cachelen-ID_CACHE_LIMIT:cachelen]
}
}
}
}
/* entry point */
func youtube_run(config map[string]interface{}) {
API_POLL_TIMEOUT := 1 /* polling timeout in seconds */
if _, ok := config["yt_api_root_url"]; ok {
API_ROOT = config["yt_api_root_url"].(string)
} else {
log.Fatal("API Root URL missing in config!")
}
if _, ok := config["yt_api_poll_timeout"]; ok {
API_POLL_TIMEOUT = int(config["yt_api_poll_timeout"].(float64))
}
video_id := "" /* video broadcast ID is empty by default */
if _, ok := config["yt_video_id"]; ok { /* override the video broadcast ID */
video_id = config["yt_video_id"].(string)
} else { /* no video ID supplied, begin with the usual flow */
/* first, get channel ID by YouTube handle */
channel_id_data := api_get("/channels?part=id&forHandle=" + config["yt_channel"].(string))
channel_id := ""
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!")
}
/* then, get live broadcast video ID by channel ID */
video_id_data := api_get("/search?part=snippet&eventType=live&type=video&channelId=" + channel_id)
if _, ok := video_id_data["items"]; ok && len(video_id_data["items"].([]interface{})) > 0 {
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!")
}
}
/* now, get active live chat ID by broadcast video ID */
chat_id := ""
chat_id_data := api_get("/videos?part=liveStreamingDetails&id=" + video_id)
if _, ok := chat_id_data["items"]; ok && len(chat_id_data["items"].([]interface{})) > 0 {
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!")
}
if len(chat_id) < 1 { /* the chat ID is still empty */
log.Fatal("Unknown error while getting live chat ID!")
}
log.Println("StreamGoose YouTube Go backend started")
var raw_chat_data map[string]interface{}
msg_id_cache = append(msg_id_cache, "") /* init the cache */
for { /* main loop */
raw_chat_data = api_get("/liveChat/messages?part=snippet,authorDetails&liveChatId=" + chat_id)
if _, ok := raw_chat_data["items"]; ok && len(raw_chat_data["items"].([]interface{})) > 0 {
for _, rawmsg := range raw_chat_data["items"].([]interface{}) { /* iterate over raw message objects */
yt_process_message(rawmsg.(map[string]interface{}))
}
}
time.Sleep(time.Duration(API_POLL_TIMEOUT) * time.Second) /* wait for specified amount before polling next */
}
}