/* 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])] = "#TWEMOTE-" + 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 := "[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 { 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("[twitch] 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) } } }