initial upload
This commit is contained in:
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
module streamgoose
|
||||
|
||||
go 1.23.1
|
||||
|
||||
require centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16
|
||||
@@ -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
@@ -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, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
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
@@ -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
@@ -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 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user